Aller au contenu
← Retour aux projets

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.

📖 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 {
  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 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)

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 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
  • 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 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() (or $sex->ensure() on an instance) 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.

Nouvelle version disponible.