Zieex Framework
A lightweight, AJAX-first PHP framework built for shared hosting โ with MVC, REST API, authentication, and a zero-dependency frontend layer.
Requirements
| Dependency | Version | Notes |
|---|---|---|
| PHP | 8.2+ | Older versions are not supported |
| Composer | Latest | For autoloading & packages |
| MySQL / PostgreSQL / SQLite | Any | MySQL is the default |
| Apache mod_rewrite | โ | Required for clean URLs |
Installation
Web Installer (Recommended)
Best for shared hosting โ no terminal access needed.
- Upload all framework files to your
public_html - Visit your domain โ the installer launches automatically
- Fill in app name, database credentials, and admin account on one page
- Done โ the installer locks itself after setup
CLI Installer
composer install
cp .env.example .env
php core/CLI/zx key:generate
php core/CLI/zx migrate
.env Setup
APP_NAME="My App"
APP_ENV=local
APP_KEY=
APP_URL=http://localhost
DB_DRIVER=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=myapp
DB_USERNAME=root
DB_PASSWORD=
Directory Structure
โโโ app/
โ โโโ Controllers/ # Your controllers
โ โโโ Models/ # Your models
โ โโโ Middleware/ # Custom middleware
โโโ core/ # Framework internals (do not edit)
โ โโโ Router/
โ โโโ Http/
โ โโโ Auth/
โ โโโ Database/
โ โโโ Template/
โ โโโ Mail/
โ โโโ Cache/
โ โโโ CLI/zx
โ โโโ helpers.php
โโโ config/ # app, database, mail, middleware
โโโ routes/
โ โโโ web/index.php # Web routes
โ โโโ api/index.php # API & mobile routes
โโโ resources/views/ # .ze.php templates
โ โโโ layouts/
โ โโโ errors/
โโโ storage/ # Logs, cache, sessions
โโโ public/assets/ # JS, CSS
โโโ install/ # Web installer (auto-locks after use)
โโโ index.php # Entry point
Configuration
All config files live in config/ and are accessible via the config() helper.
config('app.name'); // App name from config/app.php
config('database.driver'); // DB driver from config/database.php
config('mail.driver'); // Mail driver from config/mail.php
env('APP_ENV', 'local'); // Read directly from .env
Routing
Basic Routes
Router::get('/', [HomeController::class, 'index']);
Router::post('/contact', [ContactController::class, 'store']);
Router::put('/posts/{id}', [PostController::class, 'update']);
Router::delete('/posts/{id}', [PostController::class, 'destroy']);
Router::any('/webhook', [WebhookController::class, 'handle']);
Route Groups
Router::group(['prefix' => '/admin', 'middleware' => ['auth', 'role:admin']], function () {
Router::get('/users', [UserController::class, 'index']);
Router::get('/settings', [SettingsController::class, 'index']);
});
// JWT-protected API group
Router::group(['prefix' => '/api', 'middleware' => ['jwt']], function () {
Router::resource('/posts', PostController::class);
});
Resource Routes
A single Router::resource() registers 5 routes automatically:
Router::resource('/api/posts', PostController::class);
// Registers:
// GET /api/posts โ index()
// GET /api/posts/{id} โ show()
// POST /api/posts โ store()
// PUT /api/posts/{id} โ update()
// DELETE /api/posts/{id} โ destroy()
Rate Limiting & Named Routes
Router::post('/login', [AuthController::class, 'login'])
->rateLimit(5, 60) // 5 attempts per 60 seconds
->name('auth.login'); // Named route
Controllers
<?php
declare(strict_types=1);
namespace App\Controllers;
use Zieex\Http\Controller;
use Zieex\Http\Request;
use Zieex\Http\Response;
use App\Models\Post;
class PostController extends Controller
{
public function index(Request $request): Response
{
$posts = Post::all();
return $this->view('posts.index', compact('posts'));
}
public function store(Request $request): Response
{
$data = $this->validate($request, [
'title' => 'required|max:255',
'content' => 'required',
'status' => 'required|in:draft,published',
]);
$post = Post::create($data);
return $this->success($post, 'Post created.', 201);
}
}
Available Controller Methods
| Method | Description |
|---|---|
$this->view('name', $data) |
Render a .ze.php template |
$this->validate($request, $rules) |
Validate request and return typed data |
$this->success($data, $msg, $code) |
Return a consistent JSON success response |
$this->error($msg, $code) |
Return a JSON error response |
$this->redirect('/path') |
HTTP redirect |
Models & ORM
<?php
declare(strict_types=1);
namespace App\Models;
use Zieex\Database\Model;
class Post extends Model
{
protected static string $table = 'posts';
}
Model Methods
// Fetch
Post::all();
Post::find(1);
Post::where('status', 'published')->get();
Post::where('role', 'admin')->orderBy('created_at', 'DESC')->limit(10)->get();
Post::where('category', 'news')->paginate(15, $page);
// Write
Post::create(['title' => 'Hello', 'content' => 'World']);
Post::update(['id' => 1], ['title' => 'Updated']);
Post::delete(['id' => 1]);
// Existence check
Post::where('slug', $slug)->exists();
Post::count();
Templates (.ze.php)
Zieex uses a Blade-inspired template engine. Files use the .ze.php extension and are cached
automatically in production.
Layout
<!DOCTYPE html>
<html>
<head>
<title>{{ $title ?? config('app.name') }}</title>
</head>
<body>
@flash('success')
@flash('error')
@yield('content')
</body>
</html>
Child View
@extends('layouts.app')
@section('content')
<h1>{{ $title }}</h1>
@foreach($posts as $post)
<article>
<h2>{{ $post['title'] }}</h2>
<p>{!! $post['html_content'] !!}</p>
</article>
@endforeach
@auth
<a href='/posts/create'>New Post</a>
@endauth
@guest
<a href='/login'>Login to post</a>
@endguest
@endsection
Template Directives
| Directive | Description |
|---|---|
{{ $var }} |
Echo escaped output |
{!! $var !!} |
Echo raw/unescaped HTML |
@extends('layout') |
Extend a layout |
@section('name') โฆ @endsection |
Define a section |
@yield('name') |
Output a section in the layout |
@include('partial') |
Include another view |
@if / @elseif / @else / @endif |
Conditionals |
@foreach / @endforeach |
Loop over arrays |
@auth / @endauth |
Render if user is logged in |
@guest / @endguest |
Render if user is a guest |
@csrf |
Insert CSRF hidden field |
@method('PUT') |
Spoof HTTP method |
@flash('success') |
Show flash message |
@dd($var) |
Dump and die |
@php โฆ @endphp |
Raw PHP block |
Middleware
Middleware intercepts the request before it reaches your controller. Apply to individual routes or groups.
Built-in Middleware
| Key | Class | Description |
|---|---|---|
auth |
AuthMiddleware | Requires session login |
jwt |
JwtMiddleware | Requires valid JWT Bearer token |
csrf |
CsrfMiddleware | Validates CSRF token on POST (auto-applied) |
role:admin |
RoleMiddleware | Requires user to have a role |
throttle |
ThrottleMiddleware | Rate limiting |
Custom Middleware
class VerifySubscription
{
public function __construct(private ?string $params = null) {}
public function handle(Request $request, callable $next): Response
{
$user = Auth::user();
if (!$user || $user['subscribed'] !== 1) {
return redirect('/upgrade');
}
return $next();
}
}
Register in config/middleware.php:
return [
'auth' => \Zieex\Middleware\AuthMiddleware::class,
'jwt' => \Zieex\Middleware\JwtMiddleware::class,
'subscribed' => \App\Middleware\VerifySubscription::class,
];
Validation
Zieex validation returns typed, sanitized data. Failed validation throws a ValidationException
which is caught automatically by the framework.
$data = $this->validate($request, [
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'age' => 'required|integer|min:18',
'password' => 'required|min:8|confirmed',
'role' => 'required|in:admin,editor,viewer',
'website' => 'nullable|url',
'bio' => 'nullable|string|max:1000',
'tags' => 'nullable|array',
]);
// $data is typed: age is (int), confirmed passwords pass, etc.
Validation Rules
| Rule | Description |
|---|---|
required |
Field must be present and non-empty |
string |
Must be a string |
email |
Valid email format |
integer |
Must be an integer (cast automatically) |
numeric |
Must be numeric |
boolean |
Must be boolean (cast automatically) |
url |
Valid URL |
date |
Parseable date string |
min:N |
Minimum length or value |
max:N |
Maximum length or value |
in:a,b,c |
Value must be in list |
not_in:a,b |
Value must not be in list |
confirmed |
Must match field_confirmation |
unique:table,col |
Must not exist in DB |
exists:table,col |
Must exist in DB |
regex:/pattern/ |
Match a regex |
json |
Valid JSON string |
array |
Must be an array (or valid JSON array) |
nullable |
Allow null / empty |
Authentication
Session Auth
use Zieex\Auth\Auth;
// Login
$success = Auth::attempt($email, $password); // returns bool
// Check status
Auth::check(); // true if logged in
Auth::guest(); // true if NOT logged in
Auth::user(); // returns user array or null
Auth::id(); // returns user ID or null
// Logout
Auth::logout();
// Password utilities
$hash = Auth::hash($password); // bcrypt
$valid = Auth::verify($password, $hash); // verify
JWT Auth
use Zieex\Auth\JWT;
// Generate a token
$token = JWT::encode(['user_id' => $user['id'], 'role' => $user['role']]);
// Decode and verify
$payload = JWT::decode($token); // throws on invalid/expired
// Access payload in controller
$payload = $request->jwtPayload();
$userId = $payload['user_id'];
Database & Query Builder
Raw Query Builder
use Zieex\Database\DB;
// Select
DB::table('users')->where('role', 'admin')->get();
DB::table('users')->where('active', 1)->orderBy('name')->limit(20)->get();
DB::table('users')->select(['id', 'name', 'email'])->where('role', 'editor')->get();
// Pagination
$result = DB::table('posts')->paginate(15, $currentPage);
// Returns: ['data' => [...], 'total' => N, 'per_page' => 15, ...]
// Transactions
DB::transaction(function () {
DB::table('orders')->insert([...]);
DB::table('inventory')->where('id', $itemId)->update([...]);
});
Migrations
DB::schema()->create('posts', function ($table) {
$table->uuid('id')->primary();
$table->string('title', 255);
$table->text('content');
$table->enum('status', ['draft', 'published'])->default('draft');
$table->uuid('user_id');
$table->timestamps(); // created_at, updated_at
});
uuid() in
your migrations for consistency, and the uuid() helper to generate IDs.
Zieex supports multiple mail drivers simultaneously. Use the right driver for each context.
use Zieex\Mail\Mail;
// Transactional - Resend API
Mail::driver('resend')
->to('user@example.com')
->subject('Your OTP Code')
->view('emails.otp', ['code' => $code])
->send();
// Bulk - SMTP
Mail::driver('smtp')
->from('newsletter@myapp.com', 'My App')
->to('subscriber@example.com')
->subject('Monthly Update')
->html('<h1>Hello!</h1>')
->cc('manager@myapp.com')
->send();
Mail Drivers
| Driver | Best for | Config key |
|---|---|---|
smtp |
Bulk, newsletters | MAIL_SMTP_* |
resend |
Auth emails, OTPs | RESEND_API_KEY |
api |
Notifications | MAIL_API_* |
mail |
Transactional (simple) | โ |
Events
use Zieex\Event;
// Register listeners
Event::on('user.registered', function ($user) {
Mail::driver('resend')
->to($user['email'])
->subject('Welcome!')
->view('emails.welcome', ['user' => $user])
->send();
});
// Emit events
Event::emit('user.registered', $user);
Event::emit('order.placed', $order);
Logging
Logs are written to storage/logs/. In production, errors are never exposed to the browser โ only
logged.
use Zieex\Log;
Log::info('User logged in', ['user_id' => $user['id']]);
Log::warning('Rate limit approaching', ['ip' => $ip, 'attempts' => $count]);
Log::error('Payment failed', ['order_id' => $orderId]);
Log::debug('Query executed', ['sql' => $sql]);
// Stored in: storage/logs/YYYY-MM-DD.log
Cache
File-based cache stored in storage/cache/. Disabled automatically in local environment.
use Zieex\Cache\Cache;
// Store for 600 seconds
Cache::set('user:42', $userData, 600);
// Retrieve
$user = Cache::get('user:42'); // null if expired
// Remember pattern (fetch + cache in one)
$posts = Cache::remember('posts:all', 300, fn() => Post::all());
// Delete / flush
Cache::delete('user:42');
Cache::flush();
Helpers
Global helper functions available everywhere in your application.
| Helper | Returns | Description |
|---|---|---|
env('KEY', 'default') |
mixed | Read .env variable |
config('app.name') |
mixed | Read config value |
view('home', $data) |
Response | Render a template |
redirect('/url') |
Response | HTTP redirect |
response() |
Response | New response object |
base_path('storage') |
string | Absolute path from root |
storage_path('logs') |
string | Path inside storage/ |
asset('css/app.css') |
string | Full URL to public asset |
url('/posts') |
string | Full URL for a path |
uuid() |
string | Generate a UUID v4 |
now('Y-m-d') |
string | Current datetime |
flash('success', 'Done!') |
void | Set a flash message |
flash_get('success') |
?string | Read & clear flash |
old('email') |
mixed | Re-populate form field |
csrf_token() |
string | Current CSRF token |
csrf_field() |
string | Hidden CSRF input HTML |
back() |
Response | Redirect to previous URL |
abort(404) |
never | Abort with error page |
dd($var) |
never | Dump variable and exit |
dump($var) |
void | Dump variable, continue |
app() |
Application | Application instance |
cache() |
Cache | Cache instance |
auth() |
Auth | Auth instance |
Interaction.js
Zieex intercepts all links, forms, and buttons by default โ loading responses without a page reload, like Inertia or HTMX, but built from scratch with zero dependencies.
Default Behavior
<!-- Links: AJAX by default -->
<a href="/dashboard">Dashboard</a>
<!-- Opt out: full page load -->
<a href="/download" data-no-ajax>Download File</a>
<!-- Forms: AJAX submit by default -->
<form method="POST" action="/posts">
@csrf
<input name="title" />
<button>Submit</button>
</form>
Button Actions
<!-- DELETE with confirm dialog -->
<button
data-action="/posts/1"
data-method="DELETE"
data-confirm="Delete this post?">
Delete
</button>
<!-- Target a specific element to update -->
<form data-target="#results" method="POST" action="/search">
@csrf
<input name="q" placeholder="Search..." />
<button>Search</button>
</form>
<div id="results"></div>
Interaction.js Attributes
| Attribute | Element | Description |
|---|---|---|
data-no-ajax |
a, form | Force full page load |
data-action="/url" |
button | URL to POST/DELETE to |
data-method="DELETE" |
button | Override HTTP method |
data-confirm="msg" |
button, a | Show confirmation dialog |
data-target="#el" |
form | Update this element with response |
data-loading="..." |
button | Label during request |
data-on="event:method" |
any | LiveComponent event binding |
LiveComponent
Register reactive client-side components with local state, similar to Alpine.js but built into Zieex.
LiveComponent.register("counter", {
data: () => ({ count: 0, step: 1 }),
methods: {
increment() { this.count += this.step; },
decrement() { this.count -= this.step; },
reset() { this.count = 0; },
},
render() {
return `
<p>${this.count}</p>
<button data-on="click:decrement">โ</button>
<button data-on="click:increment">+</button>
`;
}
});
<!-- Mount a component -->
<div data-live="counter"></div>
<!-- Mount with initial data -->
<div data-live="counter" data-props='{"step":5}'></div>
CLI (zx)
The zx CLI lives at core/CLI/zx. Run it with
php core/CLI/zx <command>.
# Generate files
php core/CLI/zx make:controller PostController
php core/CLI/zx make:model Post
php core/CLI/zx make:middleware VerifySubscription
php core/CLI/zx make:migration create_posts_table
# Database
php core/CLI/zx migrate
# App utilities
php core/CLI/zx key:generate
php core/CLI/zx cache:clear
php core/CLI/zx serve # Start built-in dev server
Command Reference
| Command | Description |
|---|---|
make:controller Name |
Create a new controller in app/Controllers/ |
make:model Name |
Create a new model in app/Models/ |
make:middleware Name |
Create a new middleware in app/Middleware/ |
make:migration name |
Create a timestamped migration file |
migrate |
Run all pending migrations |
key:generate |
Generate and write APP_KEY to .env |
cache:clear |
Delete all cached views and data |
serve |
Start PHP built-in web server on localhost:8000 |
help |
List all commands |