Tiny PHP framework for LAMP stacks
12
stars
9
commits
PHP
primary language
Aug 30, 2026
updated
A lean, elegant, zero-boilerplate PHP framework for building REST APIs.
๐ API documentation
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.
PHPEz uses modern PHP features (type hints, attributes, enums, readonly properties) to generate everything automatically:
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/logout
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:
string, int, bool, floatObj subclassParsable interfaceDBDateTime or JSONDateTime.dto() / .fromDto($obj) methods to convert to/from a plain Obj DTO (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:
idcreated_at, updated_at)isDirty())toSex(), fromSex()).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:
claz/Error handling and HTTP exception system.
Provides:
HTTPException - Base API exceptionNotFoundException - HTTP 404rmbasepath())Type system and automatic serialization.
Provides:
Obj - Base class for all data objectsParsable - Interface for custom typesSerializableDateTime - Base for DateTime serializationOmitEmpty, DoNotSerialize, DoNotDeserializeHTTP routing and API orchestration.
Provides:
HTTP enum - HTTP methods (GET, POST, PUT, DELETE, REPORT)HTTPCode enum - Status codesGet, BoolGet - Query parameter accessorsApi - Individual endpoint handlerApp - Central routerfinal_json() - JSON response functionORM and database persistence.
Provides:
Database - Connection manager (singleton PDO)Model - ORM base class with CRUDCachableModel - Instance caching traitUnique, Index, NotNull, DbDefault, OnUpdate, Foreign, CustomTypeDBDateTime - MySQL datetime serializerDataException, DuplicateExceptionSession management ("SessioN eXtensions").
Provides:
Sex - Lazy-initialized session wrapper$SEX - Global instance<?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
rmbasepath)api/claz/{id:i} for int, {slug:s} for stringDatabase::cfg() is called in index.php (before $APP->startup())$SEX->ensure() instead of directly calling session_start()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, no echo, no I/O, nothing
that runs just by including the file). Files under claz/ must be safe to
include purely to discover the classes they declare, with no side effects.
Tooling (e.g. schema-alignment checks) loads every file under claz/ to
find Model subclasses; code that runs on include breaks that discovery.PHPEz is designed to be minimal and focused. Before adding features, consider:
Other than that, contributions are welcome! Please submit pull requests or open issues for bugs, feature requests, or documentation improvements.
9 commits
PHP
99.4%
Tiny PHP framework for LAMP stacks
12
stars
9
commits
PHP
primary language
Aug 30, 2026
updated
A lean, elegant, zero-boilerplate PHP framework for building REST APIs.
๐ API documentation
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.
PHPEz uses modern PHP features (type hints, attributes, enums, readonly properties) to generate everything automatically:
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/logout
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:
string, int, bool, floatObj subclassParsable interfaceDBDateTime or JSONDateTime.dto() / .fromDto($obj) methods to convert to/from a plain Obj DTO (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:
idcreated_at, updated_at)isDirty())toSex(), fromSex()).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:
claz/Error handling and HTTP exception system.
Provides:
HTTPException - Base API exceptionNotFoundException - HTTP 404rmbasepath())Type system and automatic serialization.
Provides:
Obj - Base class for all data objectsParsable - Interface for custom typesSerializableDateTime - Base for DateTime serializationOmitEmpty, DoNotSerialize, DoNotDeserializeHTTP routing and API orchestration.
Provides:
HTTP enum - HTTP methods (GET, POST, PUT, DELETE, REPORT)HTTPCode enum - Status codesGet, BoolGet - Query parameter accessorsApi - Individual endpoint handlerApp - Central routerfinal_json() - JSON response functionORM and database persistence.
Provides:
Database - Connection manager (singleton PDO)Model - ORM base class with CRUDCachableModel - Instance caching traitUnique, Index, NotNull, DbDefault, OnUpdate, Foreign, CustomTypeDBDateTime - MySQL datetime serializerDataException, DuplicateExceptionSession management ("SessioN eXtensions").
Provides:
Sex - Lazy-initialized session wrapper$SEX - Global instance<?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
rmbasepath)api/claz/{id:i} for int, {slug:s} for stringDatabase::cfg() is called in index.php (before $APP->startup())$SEX->ensure() instead of directly calling session_start()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, no echo, no I/O, nothing
that runs just by including the file). Files under claz/ must be safe to
include purely to discover the classes they declare, with no side effects.
Tooling (e.g. schema-alignment checks) loads every file under claz/ to
find Model subclasses; code that runs on include breaks that discovery.PHPEz is designed to be minimal and focused. Before adding features, consider:
Other than that, contributions are welcome! Please submit pull requests or open issues for bugs, feature requests, or documentation improvements.
9 commits
PHP
99.4%