PhpEZ
#phpEZ Framework
A lean, elegant, zero-boilerplate tiny PHP framework for building REST APIs.
Read "pee-aitch-peasy", as "PHP Easy" — it's designed to be simple, fast, and easy to use.
phpEZ is a single-file framework that aims to provide a kinda complete API stack with routing, ORM, serialization, and session management.
Be sure to have a look at the example project for a complete, working API built with phpEZ, and to the YOU MUST NOT section below before writing your own code.
#Minimum required PHP version: 8.4
phpEZ is built to run on plain, basic LAMP stacks — the kind that still powers most shared hosting in 2026. No Composer, no build step, no PHP extensions beyond the defaults: just upload phpez.php alongside your code and it works.
#Why phpEZ?
Tired of massive frameworks with thousands of files, confusing conventions, and tons of boilerplate? phpEZ eliminates all that noise through smart design patterns and automatic reflection-based code generation.
#The Problem with Existing Frameworks
- Laravel, Symfony: 10,000+ files, complex configuration, steep learning curve
- Manual serialization: Repetitive mappers and validators
- Migration hell: Database schema spread across files
- Routing confusion: Decorators, annotations, config arrays
#The phpEZ Solution
phpEZ uses modern PHP features (type hints, attributes, enums, readonly properties) to generate everything automatically:
- ✅ Zero serialization boilerplate - Reflection generates JSON serialization
- ✅ Zero mapper boilerplate - Type hints auto-convert nested objects
- ✅ Schema in code - Model properties define database schema
- ✅ Smart routing - Closures with type hints = automatic parameter injection
- ✅ Minimal files - Just 6 files for the entire framework
#Architecture Overview
#Core Components
For deployment, the whole framework is bundled into a single phpez.php file (generated by build/package.php) that you drop next to your index.php. There is no config.php; configuration (database credentials, debug flag, etc) lives directly in index.php.
sys/ # Framework source (development or cherry-pick) ├── boot.php # Framework bootstrap & autoloader ├── exceptions.php # Error handling system ├── iface.php # Type system & serialization ├── http.php # Routing & API layer ├── db.php # ORM & persistence └── sex.php # Session management phpez.php # Single-file bundle of sys/ (deployment)
If you need less than the full framework, you can cherry-pick individual files from sys/ and include them in your project, using require_once('sys/boot.php') to bootstrap the framework, touching it up not to include the other files automatically.
#Request Flow
┌─────────────────┐
│ .htaccess │ Rewrites /api/path to index.php?__p=path
└────────┬────────┘
│
▼
┌─────────────────┐
│ index.php │ require's phpez.php, configures Database::cfg(), calls App::startup()
└────────┬────────┘
│
▼
┌─────────────────┐
│ App::startup() │ Traverses directories, smartly pinpoints and loads a single route file
└────────┬────────┘
│
▼
┌─────────────────┐
│ Api::run() │ Matches route pattern, injects parameters
└────────┬────────┘
│
▼
┌─────────────────┐
│ Handler closure │ User code executes
└────────┬────────┘
│
▼
┌─────────────────┐
│ final_json() │ Serializes & returns JSON response
└─────────────────┘
#Quick Start
You can find a complete working example in example/ about user management, including login, registration, and logout functionality. Here's a quick overview of the steps to get started.
#1. Create a Model
<?php // api/claz/User.php class User extends Model { use Sexable; public string $email; #[Unique] public string $username; #[OmitEmpty] public string $hash; public function verifyPw(?string $pw): bool { return some_password_verify($pw ?? '', $this->hash); } }
#2. Create Database Table
<?php // One-time setup User::createTable();
(or you can use tools/dbalign.php to interactively align the database schema with your model definitions).
#3. Bootstrap in index.php
<?php // api/index.php require_once('phpez.php'); Sex::initGlobal(); Database::cfg( 'mysql:host=localhost;dbname=MyDatabase', 'MyUser', 'MyPassword', ); $APP = new App(__DIR__ . '/root/'); $APP->startup($_GET['__p'] ?? '');
#4. Create API Endpoint
See example for a completely implemented user management system.
<?php // api/root/user.php class LoginData extends Obj { public string $uname; #[OmitEmpty] public ?string $pass; } class UserData extends LoginData { public string $email; public string $name; public string $surn; public ?int $id; } // GET /user - Get current logged-in user $APP->get('', function () { return User::me()->dto(); }); // POST /user/register $APP->post('register', function (UserData $data) { $usr = User::fromSex(); if ($usr && !$usr->isAdmin) { HTTPException::throw(403, 'already_logged_in'); } if (!$data->pass) { HTTPException::throw(400, 'pass_required'); } if (!$usr) { $data->isAdmin = false; // self-registration can never grant admin } return User::fromDto($data)->setLast()->save(forCreate: true)->toSex()->dto(); }); // POST /user/login - Login user $APP->post('login', function (LoginData $data) { $usr = User::find($data->uname, 'uname'); if (!$usr) { sleep(2); // Rate limiting HTTPException::throw(401, 'invalid_login'); } if (!$usr->verifyPw($data->pass)) { sleep(2); HTTPException::throw(401, 'invalid_login'); } return $usr->toSex()->dto(); }); // POST /user/logout - Logout user $APP->post('logout', function () { User::require(); Sex::destroy(); });
#5. Make Requests
# Login curl -X POST http://localhost/api/user/login \ -H "Content-Type: application/json" \ -d '{"uname":"alice","pass":"password123"}' # Register curl -X POST http://localhost/api/user/register \ -H "Content-Type: application/json" \ -d '{"uname":"alice","pass":"password123","email":"alice@example.com","name":"Alice","surn":"Smith"}' # Get current user curl http://localhost/api/user/ # Logout curl -X POST http://localhost/api/user/logout
#Core Features
#1. Automatic Serialization (iface.php)
No mappers needed. Just extend Obj and use type hints:
class LoginData extends Obj { public string $uname; #[OmitEmpty] public ?string $pass; } class UserData extends LoginData { public string $email; public string $name; public string $surn; public ?int $id; } // Automatically deserializes JSON from request body $APP->post('login', function(LoginData $data) { // $data is already deserialized from JSON return $data; // Automatically serializes back to JSON }); // Automatically serializes via .dto() method $user = User::find(1); return $user->dto(); // Array converted to JSON response
Type support:
- Primitives:
string,int,bool,float - Nested objects: Any
Objsubclass - Custom types: Implement
Parsableinterface - Dates: Use
DBDateTimeorJSONDateTime - easy DTO conversion: implement quick
.dto()/.fromDto($obj)methods to convert to/from a plainObjDTO (see example/claz/User.php)
#2. Smart Routing (http.php)
Type hints automatically inject parameters:
// Routes use relative paths from file location // File: api/root/user.php → Routes become /user/{action} // GET /user - Get current user $APP->get('', function() { return User::me()->dto(); }); // POST /user/login - Login user (auto-deserialize JSON body) $APP->post('login', function(LoginData $data) { $usr = User::find($data->uname, 'uname'); if (!$usr->verifyPw($data->pass)) { HTTPException::throw(401, 'invalid_login'); } return $usr->setLast()->save()->toSex()->dto(); }); // Query parameters $APP->get('search', function(Get $q) { $query = $q->v(''); // Get $_GET['q'] with default }); // Boolean query parameters $APP->get('export', function(BoolGet $csv) { if ($csv->trueish()) { // ?csv=1 or ?csv=yes // CSV export } }); // Type-hinted dependencies $APP->post('verify', function(VerifyData $body, Database $db) { // $body is deserialized JSON // $db is injected from container });
Behind a reverse proxy (Cloudflare, Nginx, Apache, ...)? The real client IP isn't in REMOTE_ADDR anymore. Tell HTTPSrv which header to trust, then use HTTPSrv::remote_addr() instead of reading $_SERVER['REMOTE_ADDR'] directly:
// config.php or index.php, once at startup HTTPSrv::behindRevProxy('HTTP_X_FORWARDED_FOR'); // anywhere later $ip = HTTPSrv::remote_addr();
#3. ORM Persistence (db.php)
Define schema as model properties:
class User extends Model { public string $uname; public string $pass; #[Unique] public string $email; public string $name; public string $surn; #[Index('last_login')] public ?DBDateTime $last_login = null; #[DbDefault('CURRENT_TIMESTAMP')] public DBDateTime $created_at; } // Generate and create table User::createTable(); User::createDeps(); // Foreign keys // CRUD operations $user = new User(); $user->uname = 'alice'; $user->email = 'alice@example.com'; $user->save(forCreate: true); // INSERT $user->name = 'Alice'; $user->save(); // UPDATE $user = User::find(42); // Find by ID $users = User::findMany('name LIKE :name', ['name' => 'Alice%']); $user->delete(); // DTO conversion for API responses $dto = $user->dto(); // Converts to array for JSON // Load from DTO $user = (new User())->fromDto($data)->save(); // Method chaining $user->setLast()->save()->toSex()->dto();
Features:
- Auto-increment primary key
id - Automatic timestamps (
created_at,updated_at) - Type → SQL mapping (int → INT, string → VARCHAR, etc)
- Indexes and uniqueness constraints
- Foreign keys with cascade/restrict behavior
- Dirty tracking (
isDirty()) - Session persistence (
toSex(),fromSex()) - Easy DTO serialization: define your own
.dto()/.fromDto()methods (see example/claz/User.php)
#4. Session Management (sex.php)
Provides a namespaced, type-safe interface to PHP sessions with automatic lazy initialization (prevents "headers already sent" errors). It supports both a global shared instance (via static methods) and custom isolated instances.
// --- GLOBAL API (Sex::method) --- // Initialize the global session in bootstrap (index.php) Sex::initGlobal(); // Persist model to global session $user->toSex(); // Store anything globally Sex::put('current_user', $user); Sex::put('auth_token', $token); // Retrieve $user = Sex::get('current_user'); // Fluent API Sex::ensure() ->put('foo', 'bar') ->put('baz', 'qux'); // Retrieve user or throw 401 User::require(); // --- INSTANCE API ($sex->method) --- // Custom namespaced instance (avoids collisions) $customSex = new Sex('custom_namespace'); $customSex->ensure() ->put('step', 1) ->put('draft', $data); $draft = $customSex->get('draft'); // --- CLEANUP --- Sex::clear(); // Clear only the global namespace data $customSex->clear(); // Clear only the custom namespace data Sex::destroy(); // BEWARE: destroys the entire PHP session! All namespaces are gone.
#5. Error Handling (exceptions.php)
Unified error responses:
// HTTPException with metadata HTTPException::throw( code: 401, msg: 'invalid_login', more: ['attempt' => 3] ); // NotFoundException for 404 NotFoundException::throw(msg: 'user_not_found', more: ['id' => $id]); // DuplicateException for constraint violations (400 status) DuplicateException::throw(msg: 'email_already_exists');
Response format:
{ "success": false, "error": "invalid_login", "type": "HTTPException", "dbg": { "more": { "attempt": 3 }, "trx": [...] } }
#Configuration
Configuration lives directly in index.php, right after requiring the framework and before creating the App:
<?php // api/index.php require_once('phpez.php'); $debug = $_SERVER['HTTP_HOST'] === 'localhost'; Database::cfg( $_ENV['DB_DSN'], // e.g., mysql:host=localhost;dbname=myapp $_ENV['DB_USER'], // Database user $_ENV['DB_PASS'], // Database password $_ENV['DB_PREFIX'] ?? '' // Optional table prefix ); $APP = new App(__DIR__ . '/root/'); $APP->startup($_GET['__p'] ?? '');
See example/index.php for a full example.
#Directory Structure
api/
├── index.php # Entry point + configuration
├── .htaccess # URL rewriting
├── phpez.php # Framework single-file bundle (copy from release artifacts)
│
├── claz/ # Model classes (auto-loaded)
│ ├── User.php
│ ├── Post.php
│ ├── Category.php
│ └── ...
│
└── root/ # Route handlers
├── index.php # Global routes
├── users.php # /users routes
├── users/
│ ├── index.php # /users/* routes
│ └── profile.php # /users/profile routes
├── posts.php
└── ...
#File Reference
#phpez.php / boot.php
phpez.php is the single-file bundle you require in production; sys/boot.php is its source (used directly in development). Bootstraps the framework and sets up autoloading.
Registers:
- PSR-4 autoloader for classes in
claz/ - Exception handlers (error, exception, shutdown) with included debug stack traces
- All framework components in dependency order
#exceptions.php
Error handling and HTTP exception system.
Provides:
HTTPException- Base API exceptionNotFoundException- HTTP 404- Exception handlers (converts errors to exceptions)
- Error formatting for JSON responses
- Path sanitization (
rmbasepath())
#iface.php
Type system and automatic serialization.
Provides:
Obj- Base class for all data objectsParsable- Interface for custom typesSerializableDateTime- Base for DateTime serialization- Attributes:
OmitEmpty,DoNotSerialize,DoNotDeserialize - Reflection-based serialization/deserialization
#http.php
HTTP routing and API orchestration.
Provides:
HTTPenum - HTTP methods (GET, POST, PUT, DELETE, REPORT)HTTPCodeenum - Status codesGet,BoolGet- Query parameter accessorsApi- Individual endpoint handlerApp- Central routerfinal_json()- JSON response function
#db.php
ORM and database persistence.
Provides:
Database- Connection manager (singleton PDO)Model- ORM base class with CRUDCachableModel- Instance caching trait- Attributes:
Unique,Index,NotNull,DbDefault,OnUpdate,Foreign,CustomType DBDateTime- MySQL datetime serializerDataException,DuplicateException
#sex.php
Session management ("SessioN eXtensions").
Provides:
Sex- Lazy-initialized session wrapper- Namespaced session storage
- Fluent API for method chaining
#Examples
#Complete User Registration Flow
<?php // api/claz/User.php class User extends Model { #[Unique] public string $email; #[Unique] public string $username; public string $name; public string $surn; #[DoNotSerialize] protected string $hash; // see example/claz/User.php for a full salted-hash implementation public function setPw(string $pw): static { $this->hash = password_hash($pw, PASSWORD_DEFAULT); return $this; } public function verifyPw(string $pw): bool { return password_verify($pw, $this->hash); } } // api/root/users.php $APP->post('/register', function(RegisterRequest $body) { // Validate uniqueness if (User::find($body->email, 'email')) { DuplicateException::throw(msg: 'email_already_exists'); } // Create and persist $user = new User(); $user->email = $body->email; $user->username = $body->username; $user->setPw($body->password); $user->save(forCreate: true); // Store in session $user->toSex('current_user'); return ['success' => true, 'user_id' => $user->id()]; }); // api/root/login.php $APP->post('/login', function(LoginRequest $body) { $user = User::find($body->email, 'email'); if (!$user || !$user->verifyPw($body->password)) { HTTPException::throw(code: 401, msg: 'invalid_credentials'); } $user->toSex('current_user'); return ['success' => true, 'user_id' => $user->id()]; }); // api/root/me.php $APP->get('/me', function() { $user = User::fromSex(); // Retrieves from session if (!$user) { HTTPException::throw(code: 401, msg: 'not_authenticated'); } return $user; });
#Complex Query with Relationships
<?php // api/claz/Post.php class Post extends Model { public string $title; public string $content; #[Foreign(User::class, DbThen::CASCADE)] public int $user_id; public User $author { get => User::find($this->user_id) ?? throw new DataException('no_author'); } } // api/root/posts.php $APP->get('/posts', function(Get $status) { $cond = 'status = :status'; $params = [ 'status' => $status->v('published'), ]; return Post::findMany($cond, $params); }); $APP->get('/users/{id:i}/posts', function(int $id) { $user = User::find($id); return Post::findMany('user_id = :uid', ['uid' => $user->id()]); });
#Tips & Best Practices
#1. Use Readonly Properties for Timestamps
#[DoNotSerialize] #[NotNull] #[DbDefault('CURRENT_TIMESTAMP')] public protected(set) ?DBDateTime $created_at = null;
The protected(set) prevents accidental modification while allowing database initialization.
#2. Separate Request/Response DTOs
class CreatePostRequest extends Obj { public string $title; public string $content; } class PostResponse extends Obj { public int $id; public string $title; public string $content; public DBDateTime $created_at; }
#3. Use Attributes for Validation Hints
class BlogPost extends Model { #[NotNull] // Explicitly required public string $title; #[OmitEmpty] // Optional, omitted from serialization if unset public ?string $excerpt = null; }
#4. Implement beforeSave() for Business Logic
class User extends Model { public function beforeSave() { // Normalize email $this->email = strtolower(trim($this->email)); // Generate slug from username $this->slug = strtolower(str_replace(' ', '-', $this->username)); } }
#5. Use CachableModel for Frequently Fetched Records
class User extends Model { use CachableModel; public static function find(string $id_or_val, string $field = 'id'): ?static { // ... find implementation } } // Usage: $user1 = User::findById(42); // Hits database $user2 = User::findById(42); // Returns cached instance
#Performance Notes
- Single database connection: PDO singleton, persistent connections
- Instance caching: CachableModel reduces redundant queries
- Lazy session initialization: Sessions only start when accessed
- Reflection caching: PHP caches reflection results
- No ORMs overhead: Direct parameterized queries for complex logic
#Security Features
- SQL injection prevention: Parameterized queries throughout
- Type validation: Type hints enforced during deserialization
- Error sanitization: File paths hidden in production (
rmbasepath) - Path traversal protection: Route startup validates path components
- Session namespacing: Prevents conflicts with other data
#Troubleshooting
#"Class not found" errors
- Check the class file exists in
api/claz/ - Verify namespace matches directory structure
- Check for typos in class name
#Routes not matching
- Verify route is registered in correct file
- Check path pattern syntax:
{id:i}for int,{slug:s}for string - Routes are matched in order, first match wins
#Database connection errors
- Verify
Database::cfg()is called inindex.php(before$APP->startup()) - Check database credentials in environment variables
- Ensure database exists and user has permissions
#"Headers already sent" errors
- Use
Sex::ensure()(or$sex->ensure()on an instance) instead of directly callingsession_start() - phpEZ handles lazy session initialization
#YOU MUST NOT
Rules the framework relies on but can't enforce at runtime:
claz/files must only contain definitions. No top-level statements, no side effects in the global scope (no DB calls, noecho, no I/O, nothing that runs just by including the file). Files underclaz/must be safe toincludepurely to discover the classes they declare, with no side effects. Tooling (e.g. schema-alignment checks) loads every file underclaz/to findModelsubclasses; code that runs on include breaks that discovery.
#Contributing
phpEZ is designed to be minimal and focused. Before adding features, consider:
- Does it increase file count significantly?
- Could the same result be achieved with a simpler approach?
- Is it solving a real problem, or adding theoretical flexibility?
Other than that, contributions are welcome! Please submit pull requests or open issues for bugs, feature requests, or documentation improvements.