A lean, elegant, zero-boilerplate PHP framework for building REST APIs.
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.
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.
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.
- 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
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
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.
┌─────────────────┐
│ .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
└─────────────────┘
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.
<?php
// api/claz/User.php
class User extends Model {
public string $email;
#[Unique]
public string $username;
#[DoNotSerialize]
public string $password_hash;
public function verifyPw(?string $pw): bool {
return some_password_verify($pw ?? '', $this->password_hash);
}
}<?php
// One-time setup
User::createTable();<?php
// api/index.php
require_once('phpez.php');
Database::cfg(
'mysql:host=localhost;dbname=MyDatabase',
'MyUser',
'MyPassword',
);
$APP = new App(__DIR__ . '/root/');
$APP->startup($_GET['__p'] ?? '');<?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();
global $SEX;
$SEX->destroy();
});# 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/logoutNo 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 responseType 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)
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();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)
Lazy-initialized, namespaced sessions:
global $SEX;
// Persist model to session
$user->toSex();
// Store anything
$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();
// Cleanup
$SEX->destroy();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 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.
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
└── ...
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)
- All framework components in dependency order
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())
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 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
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
Session management ("SessioN eXtensions").
Provides:
Sex- Lazy-initialized session wrapper$SEX- Global instance- Namespaced session storage
- Fluent API for method chaining
<?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() {
global $SEX;
$user = $SEX->get('current_user');
if (!$user) {
HTTPException::throw(code: 401, msg: 'not_authenticated');
}
return $user;
});<?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()]);
});#[DoNotSerialize]
#[NotNull]
#[DbDefault('CURRENT_TIMESTAMP')]
public protected(set) ?DBDateTime $created_at = null;The protected(set) prevents accidental modification while allowing database initialization.
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;
}class BlogPost extends Model {
#[NotNull] // Explicitly required
public string $title;
#[OmitEmpty] // Optional, omitted from serialization if unset
public ?string $excerpt = null;
}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));
}
}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- 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
- 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
- Check the class file exists in
api/claz/ - Verify namespace matches directory structure
- Check for typos in class name
- 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
- Verify
Database::cfg()is called inindex.php(before$APP->startup()) - Check database credentials in environment variables
- Ensure database exists and user has permissions
- Use
$SEX->ensure()instead of directly callingsession_start() - PHPEz handles lazy session initialization
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.
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.