Skip to content

Latest commit

 

History

9 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PHPEz Framework

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.

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 {
  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);
  }
}

2. Create Database Table

<?php
// One-time setup
User::createTable();

3. Bootstrap in index.php

<?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'] ?? '');

4. Create API Endpoint

<?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();
});

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 Obj subclass
  • Custom types: Implement Parsable interface
  • Dates: Use DBDateTime or JSONDateTime
  • easy DTO conversion: implement quick .dto() / .fromDto($obj) methods to convert to/from a plain Obj DTO (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)

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();

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)
  • All framework components in dependency order

exceptions.php

Error handling and HTTP exception system.

Provides:

  • HTTPException - Base API exception
  • NotFoundException - 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 objects
  • Parsable - Interface for custom types
  • SerializableDateTime - Base for DateTime serialization
  • Attributes: OmitEmpty, DoNotSerialize, DoNotDeserialize
  • Reflection-based serialization/deserialization

http.php

HTTP routing and API orchestration.

Provides:

  • HTTP enum - HTTP methods (GET, POST, PUT, DELETE, REPORT)
  • HTTPCode enum - Status codes
  • Get, BoolGet - Query parameter accessors
  • Api - Individual endpoint handler
  • App - Central router
  • final_json() - JSON response function

db.php

ORM and database persistence.

Provides:

  • Database - Connection manager (singleton PDO)
  • Model - ORM base class with CRUD
  • CachableModel - Instance caching trait
  • Attributes: Unique, Index, NotNull, DbDefault, OnUpdate, Foreign, CustomType
  • DBDateTime - MySQL datetime serializer
  • DataException, DuplicateException

sex.php

Session management ("SessioN eXtensions").

Provides:

  • Sex - Lazy-initialized session wrapper
  • $SEX - Global instance
  • 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() {
  global $SEX;
  $user = $SEX->get('current_user');

  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 in index.php (before $APP->startup())
  • Check database credentials in environment variables
  • Ensure database exists and user has permissions

"Headers already sent" errors

  • Use $SEX->ensure() instead of directly calling session_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, 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.

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.

About

Tiny PHP framework

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages