PHPackages                             zero-to-prod/web-framework - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Framework](/categories/framework)
4. /
5. zero-to-prod/web-framework

ActiveLibrary[Framework](/categories/framework)

zero-to-prod/web-framework
==========================

A simple web framework for PHP

v0.0.21(5mo ago)0504MITPHPPHP &gt;=7.1CI passing

Since Nov 29Pushed 5mo agoCompare

[ Source](https://github.com/zero-to-prod/web-framework)[ Packagist](https://packagist.org/packages/zero-to-prod/web-framework)[ Docs](https://github.com/zero-to-prod/web-framework)[ Fund](https://github.com/sponsors/zero-to-prod)[ RSS](/packages/zero-to-prod-web-framework/feed)WikiDiscussions main Synced 1mo ago

READMEChangelog (1)Dependencies (9)Versions (2)Used By (0)

Zerotoprod\\WebFramework
========================

[](#zerotoprodwebframework)

[![](art/logo.png)](art/logo.png)

[![Repo](https://camo.githubusercontent.com/9a90a3efeee26aed7d7f2feee9cd84566a26f9c362cc773b184d076210906e1c/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6769746875622d677261793f6c6f676f3d676974687562)](https://github.com/zero-to-prod/web-framework)[![GitHub Actions Workflow Status](https://camo.githubusercontent.com/42ce4e5d9fd0d1066104790960f49586780c5377c649b0b60030288459af6ffd/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f7a65726f2d746f2d70726f642f7765622d6672616d65776f726b2f746573742e796d6c3f6c6162656c3d74657374)](https://github.com/zero-to-prod/web-framework/actions)[![GitHub Actions Workflow Status](https://camo.githubusercontent.com/01cda6eef102b6cc435374d0ee2333c6ec3e24ad7855e5e3a7eb9e0b7e7909da/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f7a65726f2d746f2d70726f642f7765622d6672616d65776f726b2f6261636b77617264735f636f6d7061746962696c6974792e796d6c3f6c6162656c3d6261636b77617264735f636f6d7061746962696c697479)](https://github.com/zero-to-prod/web-framework/actions)[![Packagist Downloads](https://camo.githubusercontent.com/8770bff32d3b93f29b12794e9581127a7a5b785917a9610fc0c9d7a3eaf421c2/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f7a65726f2d746f2d70726f642f7765622d6672616d65776f726b3f636f6c6f723d626c7565)](https://packagist.org/packages/zero-to-prod/web-framework/stats)[![php](https://camo.githubusercontent.com/d281327acae14de9c99b10019015c93ef674c8aa56f543089926b26cb56a10fa/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f7a65726f2d746f2d70726f642f7765622d6672616d65776f726b2e7376673f636f6c6f723d707572706c65)](https://packagist.org/packages/zero-to-prod/web-framework/stats)[![Packagist Version](https://camo.githubusercontent.com/ce676ca30aa80e9be1397bdbd8d0af794a77c286f91b14f615d327978235a9a0/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f7a65726f2d746f2d70726f642f7765622d6672616d65776f726b3f636f6c6f723d663238643161)](https://packagist.org/packages/zero-to-prod/web-framework)[![License](https://camo.githubusercontent.com/cf52bd7a92aef0e0aa7294075d97d224a48db47d343c9dd16f97d65c67be1057/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f7a65726f2d746f2d70726f642f7765622d6672616d65776f726b3f636f6c6f723d70696e6b)](https://github.com/zero-to-prod/web-framework/blob/main/LICENSE.md)[![wakatime](https://camo.githubusercontent.com/223d36456352bbbcc9bbb6d152dce0cee85c06c38a63764acf8d31d7e170e905/68747470733a2f2f77616b6174696d652e636f6d2f62616467652f6769746875622f7a65726f2d746f2d70726f642f7765622d6672616d65776f726b2e737667)](https://wakatime.com/badge/github/zero-to-prod/web-framework)[![Hits-of-Code](https://camo.githubusercontent.com/13afa420a8523a4b15802939f1743576b46cb14c710482be94debbbe561e8731/68747470733a2f2f686974736f66636f64652e636f6d2f6769746875622f7a65726f2d746f2d70726f642f7765622d6672616d65776f726b3f6272616e63683d6d61696e)](https://hitsofcode.com/github/zero-to-prod/web-framework/view?branch=main)

Contents
--------

[](#contents)

- [Introduction](#introduction)
- [Requirements](#requirements)
- [Installation](#installation)
- [Documentation Publishing](#documentation-publishing)
    - [Automatic Documentation Publishing](#automatic-documentation-publishing)
- [Usage](#usage)
    - [Environment Variables](#environment-variables)
        - [Overview](#overview)
        - [Usage](#usage-1)
        - [Immutable Binding](#immutable-binding)
        - [Custom Target Array](#custom-target-array)
        - [Return Value](#return-value)
    - [WebFramework Core](#webframework-core)
        - [Overview](#overview-1)
        - [Basic Usage](#basic-usage)
        - [Environment Management](#environment-management)
        - [Server Management](#server-management)
        - [Container](#container)
        - [Context Callback](#context-callback)
        - [Method Chaining](#method-chaining)
    - [HTTP Routing](#http-routing)
        - [Overview](#overview-2)
        - [Quick Start](#quick-start)
        - [Supported HTTP Methods](#supported-http-methods)
        - [Action Types](#action-types)
        - [Dynamic Routes with Parameters](#dynamic-routes-with-parameters)
        - [Inline Constraints](#inline-constraints)
        - [Fluent Where Constraints](#fluent-where-constraints)
        - [Optional Parameters](#optional-parameters)
        - [Route Naming](#route-naming)
        - [Additional Arguments (Dependency Injection)](#additional-arguments-dependency-injection)
        - [404 Fallback Handler](#404-fallback-handler)
        - [Middleware](#middleware)
        - [Route Caching](#route-caching)
        - [Method Chaining](#method-chaining-1)
        - [Route Matching Behavior](#route-matching-behavior)
        - [Static vs Dynamic Route Priority](#static-vs-dynamic-route-priority)
        - [Performance Characteristics](#performance-characteristics)
        - [Complete Example](#complete-example)
- [Local Development](./LOCAL_DEVELOPMENT.md)
- [Contributing](#contributing)

Introduction
------------

[](#introduction)

A simple web framework for PHP

Requirements
------------

[](#requirements)

- PHP 7.1 or higher.

Installation
------------

[](#installation)

Install `Zerotoprod\WebFramework` via [Composer](https://getcomposer.org/):

```
composer require zero-to-prod/web-framework
```

This will add the package to your project’s dependencies and create an autoloader entry for it.

Documentation Publishing
------------------------

[](#documentation-publishing)

You can publish this README to your local documentation directory.

This can be useful for providing documentation for AI agents.

This can be done using the included script:

```
# Publish to default location (./docs/zero-to-prod/web-framework)
vendor/bin/zero-to-prod-web-framework

# Publish to custom directory
vendor/bin/zero-to-prod-web-framework /path/to/your/docs
```

#### Automatic Documentation Publishing

[](#automatic-documentation-publishing)

You can automatically publish documentation by adding the following to your `composer.json`:

```
{
  "scripts": {
    "post-install-cmd": [
      "web-framework"
    ],
    "post-update-cmd": [
      "web-framework"
    ]
  }
}
```

Usage
-----

[](#usage)

### Environment Variables

[](#environment-variables)

#### Overview

[](#overview)

The `EnvBinderImmutable` plugin provides a simple static method for parsing and binding environment variables from `.env` files.

#### Usage

[](#usage-1)

```
use Zerotoprod\WebFramework\Plugins\EnvBinderImmutable;

// Read .env file content
$env_content = file_get_contents(__DIR__ . '/.env');

// Parse and bind to $_ENV immutably
$parsed = EnvBinderImmutable::parseFromString($env_content, $_ENV);

// Access environment variables
echo $_ENV['APP_NAME'];    // via $_ENV
echo getenv('APP_ENV');    // via getenv()
```

**Your `.env` file:**

```
APP_NAME=MyApplication
APP_ENV=production
DB_HOST=localhost
DB_PORT=3306
```

#### Immutable Binding

[](#immutable-binding)

The plugin ensures existing environment variables are never overwritten:

```
// Set an existing variable
$_ENV['APP_ENV'] = 'development';
putenv('APP_ENV=development');

// Load from .env file (containing APP_ENV=production)
$env_content = "APP_ENV=production\nDB_HOST=localhost";
EnvBinderImmutable::parseFromString($env_content, $_ENV);

// Original value is preserved
echo $_ENV['APP_ENV'];  // Outputs: development (not production)
echo $_ENV['DB_HOST'];  // Outputs: localhost (newly added)
```

Variables are protected if they exist in either `$_ENV` or `getenv()`.

#### Custom Target Array

[](#custom-target-array)

Use a custom array instead of `$_ENV` for testing or isolation:

```
$custom_env = [];
$env_content = file_get_contents(__DIR__ . '/.env');

EnvBinderImmutable::parseFromString($env_content, $custom_env);

// Variables are in $custom_env, not $_ENV
echo $custom_env['APP_NAME'];
```

#### Return Value

[](#return-value)

The method returns the parsed array for inspection:

```
$env_content = "APP_NAME=MyApp\nDB_HOST=localhost";
$parsed = EnvBinderImmutable::parseFromString($env_content, $_ENV);

// $parsed contains all parsed variables
// ['APP_NAME' => 'MyApp', 'DB_HOST' => 'localhost']
```

### WebFramework Core

[](#webframework-core)

#### Overview

[](#overview-1)

The `WebFramework` class provides a central container for managing environment variables, server context, and dependency injection.

#### Basic Usage

[](#basic-usage)

```
use Zerotoprod\WebFramework\WebFramework;

// Create framework instance
$framework = new WebFramework(__DIR__);

// Store environment and server arrays
$framework->setEnv($_ENV);
$framework->setServer($_SERVER);
```

#### Environment Management

[](#environment-management)

Store and retrieve environment arrays:

```
$env = ['APP_ENV' => 'production'];
$framework->setEnv($env);

// Retrieve the environment array
$stored_env = $framework->getEnv();
// Returns: ['APP_ENV' => 'production']
```

**Note:** `getEnv()` throws `RuntimeException` if not set.

#### Server Management

[](#server-management)

Store and retrieve server arrays:

```
$server = ['REQUEST_METHOD' => 'GET', 'REQUEST_URI' => '/'];
$framework->setServer($server);

// Retrieve the server array
$stored_server = $framework->getServer();
// Returns: ['REQUEST_METHOD' => 'GET', 'REQUEST_URI' => '/']
```

**Note:** `getServer()` throws `RuntimeException` if not set.

#### Container

[](#container)

Store a PSR-11 container instance:

```
$container = new YourContainer();
$framework->setContainer($container);

// Retrieve the container
$stored_container = $framework->container();
```

**Note:** `container()` throws `RuntimeException` if not set.

#### Context Callback

[](#context-callback)

Execute callbacks with the framework instance:

```
$framework->context(function ($fw) {
    // Access framework methods within callback
    $env = $fw->getEnv();
    $server = $fw->getServer();
});
```

#### Method Chaining

[](#method-chaining)

All setters return the framework instance for chaining:

```
$framework = (new WebFramework(__DIR__))
    ->setEnv($_ENV)
    ->setServer($_SERVER)
    ->setContainer($container)
    ->context(function ($fw) {
        // Configure something
    });
```

### HTTP Routing

[](#http-routing)

#### Overview

[](#overview-2)

The routing system provides a fluent, Laravel-style API for defining HTTP routes with support for:

- **Static routes:** **O(1) constant-time** hash map lookups
- **Dynamic routes:** Pattern-based matching with named parameters
- **Inline constraints:** `{id:\d+}` syntax for parameter validation
- **Optional parameters:** `{name?}` syntax
- **Where constraints:** Fluent `where()` chaining for parameter rules
- **Route naming:** Named routes for URL generation
- **Route caching:** Serialization for production performance

#### Quick Start

[](#quick-start)

```
use Zerotoprod\WebFramework\Router;

// Create router for this request with context
$routes = Router::for($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'], $_SERVER)
    ->get('/users', [UserController::class, 'index'])
    ->get('/users/{id}', [UserController::class, 'show'])
    ->post('/users', [UserController::class, 'create']);

// Dispatch request
$routes->dispatch();
```

#### Supported HTTP Methods

[](#supported-http-methods)

All HTTP method helpers return the `Router` instance for fluent chaining:

```
$routes->get('/resource', $action);      // GET requests
$routes->post('/resource', $action);     // POST requests
$routes->put('/resource', $action);      // PUT requests
$routes->patch('/resource', $action);    // PATCH requests
$routes->delete('/resource', $action);   // DELETE requests
$routes->options('/resource', $action);  // OPTIONS requests
$routes->head('/resource', $action);     // HEAD requests
$routes->any('/resource', $action);      // All HTTP methods
```

Each method returns the `Router` instance, allowing you to chain additional configuration methods like `where()`, `middleware()`, and `name()`, or continue defining more routes.

##### Any Method Routes

[](#any-method-routes)

The `any()` method registers a route for all standard HTTP methods (GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD):

```
// Responds to all HTTP methods
$routes->any('/api/webhook', [WebhookController::class, 'handle']);

// With specific methods array
$routes->any('/api/data', [DataController::class, 'process'], ['GET', 'POST']);
```

**Custom method filtering:**

```
// Only respond to GET and POST
$routes->any('/api/endpoint', $action, ['GET', 'POST']);

// Lowercase methods are automatically converted to uppercase
$routes->any('/api/resource', $action, ['get', 'post', 'put']);

// Invalid methods are silently ignored
$routes->any('/test', $action, ['GET', 'INVALID']);  // Only registers GET
```

**Important note about chaining:**The `any()` method creates multiple routes (one per HTTP method). When you chain `where()`, `name()`, or `middleware()` after `any()`, the configuration only applies to the last route created (HEAD). To apply configuration to all methods, use route groups or define routes individually.

#### Action Types

[](#action-types)

Routes support three types of actions:

##### 1. Closures

[](#1-closures)

```
$routes->get('/hello', function ($params) {
    echo "Hello, World!";
});
```

##### 2. Controller Arrays

[](#2-controller-arrays)

```
class UserController {
    public function index($params) {
        echo "User list";
    }

    public function show($params) {
        echo "Show user: " . $params['id'];
    }
}

$routes->get('/users', [UserController::class, 'index']);
$routes->get('/users/{id}', [UserController::class, 'show']);
```

##### 3. Invokable Controllers

[](#3-invokable-controllers)

```
class HomeController {
    public function __invoke($params) {
        echo "Welcome Home";
    }
}

$routes->get('/', HomeController::class);
```

#### RESTful Resource Routes

[](#restful-resource-routes)

Quickly define standard CRUD routes with the `resource()` method:

```
// Generate all 7 RESTful routes
$routes->resource('posts', PostController::class);

// Generates:
// GET    /posts           → PostController::index()    [posts.index]
// GET    /posts/create    → PostController::create()   [posts.create]
// POST   /posts           → PostController::store()    [posts.store]
// GET    /posts/{id}      → PostController::show()     [posts.show]
// GET    /posts/{id}/edit → PostController::edit()     [posts.edit]
// PUT    /posts/{id}      → PostController::update()   [posts.update]
// DELETE /posts/{id}      → PostController::destroy()  [posts.destroy]
```

**Limiting actions:**

```
// Only include specific actions
$routes->resource('photos', PhotoController::class, ['only' => ['index', 'show']]);

// Exclude specific actions
$routes->resource('users', UserController::class, ['except' => ['destroy']]);
```

**Named routes:**All resource routes are automatically named using the pattern `{resource}.{action}`:

```
$routes->resource('users', UserController::class);

// Generate URLs
$url = $routes->route('users.show', ['id' => 123]); // /users/123
$url = $routes->route('users.edit', ['id' => 456]); // /users/456/edit
```

#### Dynamic Routes with Parameters

[](#dynamic-routes-with-parameters)

##### Basic Parameters

[](#basic-parameters)

```
$routes->get('/users/{id}', function ($params) {
    echo "User ID: " . $params['id'];
    // GET /users/123 → params = ['id' => '123']
});

$routes->get('/posts/{slug}', function ($params) {
    echo "Post: " . $params['slug'];
    // GET /posts/hello-world → params = ['slug' => 'hello-world']
});
```

##### Multiple Parameters

[](#multiple-parameters)

```
$routes->get('/users/{userId}/posts/{postId}', function ($params) {
    $userId = $params['userId'];
    $postId = $params['postId'];
    echo "User $userId, Post $postId";
    // GET /users/456/posts/789 → params = ['userId' => '456', 'postId' => '789']
});
```

#### Inline Constraints

[](#inline-constraints)

Define parameter validation rules directly in the route pattern:

```
// Numeric ID only
$routes->get('/users/{id:\d+}', function ($params) {
    echo "User ID: " . $params['id'];
    // Matches: /users/123
    // Doesn't match: /users/abc
});

// Alphanumeric slug
$routes->get('/posts/{slug:[a-z0-9-]+}', function ($params) {
    echo "Post: " . $params['slug'];
    // Matches: /posts/hello-world-123
    // Doesn't match: /posts/Hello_World
});

// UUID format
$routes->get('/items/{uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}}',
    function ($params) {
        echo "Item: " . $params['uuid'];
    }
);
```

#### Fluent Where Constraints

[](#fluent-where-constraints)

Use the `where()` method for cleaner constraint definitions:

```
// Single constraint
$routes->get('/users/{id}', [UserController::class, 'show'])
    ->where('id', '\d+');

// Multiple constraints (array)
$routes->get('/posts/{year}/{month}', [PostController::class, 'archive'])
    ->where([
        'year' => '\d{4}',
        'month' => '\d{2}'
    ]);

// Chained constraints
$routes->get('/blog/{category}/{slug}', [BlogController::class, 'show'])
    ->where('category', '[a-z]+')
    ->where('slug', '[a-z0-9-]+');
```

#### Optional Parameters

[](#optional-parameters)

Make parameters optional using the `?` suffix:

```
// Optional parameter
$routes->get('/search/{query?}', function ($params) {
    $query = $params['query'] ?? 'default';
    echo "Searching for: $query";
    // Matches: /search → params = []
    // Matches: /search/php → params = ['query' => 'php']
});

// Multiple optional parameters
$routes->get('/blog/{year?}/{month?}', function ($params) {
    $year = $params['year'] ?? date('Y');
    $month = $params['month'] ?? date('m');
    echo "Archive: $year-$month";
});

// Optional with constraint
$routes->get('/users/{id:\d+?}', function ($params) {
    // Optional numeric ID
});

// Optional with where()
$routes->get('/posts/{page?}', [PostController::class, 'index'])
    ->where('page', '\d+');
```

#### Route Naming

[](#route-naming)

Name routes for URL generation:

```
$routes->get('/users/{id}', [UserController::class, 'show'])
    ->name('users.show');

$routes->post('/users', [UserController::class, 'create'])
    ->name('users.create');

// Generate URLs from named routes
$url = $routes->route('users.show', ['id' => 123]);
// Returns: /users/123
```

Route names enable URL generation while keeping route definitions centralized.

#### Route Groups

[](#route-groups)

Organize routes with shared attributes using `prefix()` and `group()`:

##### Basic Prefix Groups

[](#basic-prefix-groups)

```
// Apply prefix to multiple routes
$routes->prefix('admin')
    ->group(function ($r) {
        $r->get('/users', [AdminController::class, 'users']);     // /admin/users
        $r->get('/posts', [AdminController::class, 'posts']);     // /admin/posts
        $r->get('/settings', [AdminController::class, 'settings']); // /admin/settings
    });
```

##### Middleware Groups

[](#middleware-groups)

```
// Apply middleware to multiple routes
$routes->middleware(AuthMiddleware::class)
    ->group(function ($r) {
        $r->get('/dashboard', [DashboardController::class, 'index']);
        $r->get('/profile', [ProfileController::class, 'show']);
    });
```

##### Combined Prefix and Middleware

[](#combined-prefix-and-middleware)

```
// Apply both prefix and middleware
$routes->prefix('api')
    ->middleware([AuthMiddleware::class, RateLimitMiddleware::class])
    ->group(function ($r) {
        $r->get('/users', [ApiController::class, 'users']);
        $r->post('/users', [ApiController::class, 'createUser']);
    });
```

##### Nested Groups

[](#nested-groups)

Groups can be nested to create hierarchical route structures:

```
// Nested prefix stacking
$routes->prefix('api')
    ->group(function ($r) {
        $r->prefix('v1')
            ->group(function ($r) {
                $r->get('/users', [ApiV1Controller::class, 'users']); // /api/v1/users
                $r->get('/posts', [ApiV1Controller::class, 'posts']); // /api/v1/posts
            });

        $r->prefix('v2')
            ->group(function ($r) {
                $r->get('/users', [ApiV2Controller::class, 'users']); // /api/v2/users
            });
    });

// Nested middleware stacking
$routes->middleware(LoggingMiddleware::class)
    ->group(function ($r) {
        $r->middleware(AuthMiddleware::class)
            ->group(function ($r) {
                // Both LoggingMiddleware and AuthMiddleware apply
                $r->get('/admin', [AdminController::class, 'index']);
            });
    });
```

**How groups work:**

- `prefix()` sets the prefix for the next `group()` call
- `middleware()` sets middleware for the next `group()` call
- Nested groups stack both prefixes and middleware
- Groups automatically clean up their state after execution

#### Additional Arguments (Dependency Injection)

[](#additional-arguments-dependency-injection)

Pass dependencies to all route handlers and middleware via Router::for():

```
$database = new Database();
$logger = new Logger();

// Dependencies passed as additional arguments to Router::for()
$routes = Router::for('GET', '/users', $_SERVER, $database, $logger)
    ->get('/users', function ($params, $server, $db, $log) {
        $log->info('Fetching users');
        $users = $db->query('SELECT * FROM users');
        echo json_encode($users);
    });

$routes->dispatch();
```

**With controllers:**

```
class UserController {
    public function index($params, $server, $db, $logger) {
        $logger->info('UserController::index called');
        return $db->fetchAll('users');
    }
}

$routes = Router::for('GET', '/api/users', $_SERVER, $database, $logger)
    ->get('/api/users', [UserController::class, 'index']);

$routes->dispatch();
```

#### 404 Fallback Handler

[](#404-fallback-handler)

Define a fallback handler for unmatched routes:

```
$routes = Router::for($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'])
    ->get('/', 'Home Page')
    ->get('/about', 'About Us')
    ->fallback(function ($params) {
        http_response_code(404);
        echo '404 - Page Not Found';
    });

$routes->dispatch();
```

**Fallback with controller:**

```
class NotFoundController {
    public function __invoke($params) {
        http_response_code(404);
        include 'views/404.php';
    }
}

$routes = Router::for($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'])
    ->get('/', 'Home')
    ->fallback(NotFoundController::class);

$routes->dispatch();
```

**Fallback with dependencies:**

```
$logger = new Logger();

$routes = Router::for($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'], $_SERVER, $logger)
    ->fallback(function ($params, $server, $log) {
        $log->warning('404: ' . $server['REQUEST_URI']);
        echo '404 - Not Found';
    });

$routes->dispatch();
```

#### Middleware

[](#middleware)

##### Overview

[](#overview-3)

Middleware provides a convenient mechanism for inspecting and filtering HTTP requests entering your routes. Middleware executes before route actions, making it perfect for authentication, logging, CORS, rate limiting, and more.

Middleware receives a `$next` callable followed by any arguments passed to `Router::for()`, allowing you to pass custom context objects, dependencies, or the `$_SERVER` superglobal.

##### Quick Start

[](#quick-start-1)

```
use Zerotoprod\WebFramework\Router;

$routes = Router::for($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'], $_SERVER)
    ->globalMiddleware(function ($next, $server) {
        // Pre-action logic
        error_log("Request: {$server['REQUEST_METHOD']} {$server['REQUEST_URI']}");

        // Continue to next middleware or action
        $next();

        // Post-action logic (optional)
        error_log("Request completed");
    })
    ->get('/users', [UserController::class, 'index']);

$routes->dispatch();
```

##### Middleware Types

[](#middleware-types)

The router supports two middleware types:

1. **PSR-15 Middleware** (implements `Psr\Http\Server\MiddlewareInterface`)
2. **Variadic Middleware** (legacy callable format)

Both types can be used interchangeably and mixed freely.

##### Middleware Signature

[](#middleware-signature)

**Variadic Middleware (Legacy):**

```
function ($next, ...$context) {
    // $next is a closure to continue the middleware chain
    // $context contains all arguments passed to Router::for()
}
```

**PSR-15 Middleware (Recommended):**

```
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

class MyMiddleware implements MiddlewareInterface
{
    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler
    ): ResponseInterface {
        // Pre-action logic

        // Continue to next middleware/action
        $response = $handler->handle($request);

        // Post-action logic
        return $response;
    }
}
```

**More explicitly**:

```
function ($next, $server, $db = null, $logger = null) {
    // Middleware declares exactly what it expects
    // $server would be first context arg from Router::for()
    // $db would be second context arg (optional)
    // $logger would be third context arg (optional)
}
```

**Access to context arguments:**

- Context args are passed to `Router::for($method, $uri, ...$context)`
- First arg is typically `$_SERVER` (by convention)
- Additional args can be any dependencies (database, logger, etc.)
- Middleware declares what it needs via function parameters

##### Global Middleware

[](#global-middleware)

Register middleware that applies to all routes:

```
// Single middleware
$routes->globalMiddleware(LoggingMiddleware::class);

// Multiple middleware (executes in order)
$routes->globalMiddleware([
    AuthenticationMiddleware::class,
    CorsMiddleware::class,
    LoggingMiddleware::class
]);

// Chain middleware registration
$routes->globalMiddleware(AuthMiddleware::class)
       ->globalMiddleware(LogMiddleware::class);
```

##### Per-Route Middleware

[](#per-route-middleware)

Add middleware to specific routes:

```
// Single middleware
$routes->get('/admin', [AdminController::class, 'index'])
    ->middleware(AdminAuthMiddleware::class);

// Multiple middleware
$routes->post('/users', [UserController::class, 'store'])
    ->middleware([
        ValidateInputMiddleware::class,
        RateLimitMiddleware::class
    ]);

// Chain with other route methods
$routes->get('/profile/{id}', [ProfileController::class, 'show'])
    ->where('id', '\d+')
    ->middleware(AuthMiddleware::class)
    ->name('profile.show');
```

##### Creating Middleware Classes

[](#creating-middleware-classes)

**Invokable Class:**

```
class AuthenticationMiddleware
{
    public function __invoke($next, $server)
    {
        // Pre-action: Check authentication
        if (!isset($_SESSION['user_id'])) {
            http_response_code(401);
            echo json_encode(['error' => 'Unauthorized']);
            return; // Don't call $next() - halt execution
        }

        // Pass control to next middleware or action
        $next();

        // Post-action: Optional cleanup or logging
        error_log("Request completed by user: {$_SESSION['user_id']}");
    }
}

// Usage
$routes->dispatch();
```

**Closure Middleware:**

```
$routes->globalMiddleware(function ($next, $server) {
    $start = microtime(true);

    // Continue to action
    $next();

    // Log execution time
    $duration = microtime(true) - $start;
    error_log("{$server['REQUEST_METHOD']} {$server['REQUEST_URI']} - {$duration}s");
});

$routes->get('/users', [UserController::class, 'index']);

$routes->dispatch();
```

##### Execution Order

[](#execution-order)

Middleware executes in this order:

1. **Global middleware** (in registration order)
2. **Route-specific middleware** (in registration order)
3. **Route action**
4. **Route-specific middleware post-processing** (in reverse order)
5. **Global middleware post-processing** (in reverse order)

```
$routes = Router::for('GET', '/test')
    ->globalMiddleware(function ($next) {
        echo "1. Global before\n";
        $next();
        echo "6. Global after\n";
    })
    ->get('/test', function () {
        echo "4. Action\n";
    })
    ->middleware(function ($next) {
        echo "2. Route before\n";
        $next();
        echo "5. Route after\n";
    });

$routes->dispatch();

// Output: 1. Global before → 2. Route before → 4. Action → 5. Route after → 6. Global after
```

##### Halting Execution

[](#halting-execution)

Middleware can stop request processing by not calling `$next()`:

```
class RateLimitMiddleware
{
    public function __invoke($next, $server)
    {
        $ip = $server['REMOTE_ADDR'] ?? 'unknown';

        if ($this->isRateLimited($ip)) {
            http_response_code(429);
            echo json_encode(['error' => 'Too many requests']);
            return; // Halt - action will not execute
        }

        $next(); // Continue processing
    }
}
```

##### Practical Examples

[](#practical-examples)

**Authentication:**

```
class AuthMiddleware
{
    public function __invoke($next, $server)
    {
        if (empty($_SESSION['user_id'])) {
            http_response_code(401);
            echo json_encode(['error' => 'Unauthorized']);
            return;
        }
        $next();
    }
}
```

**CORS:**

```
class CorsMiddleware
{
    public function __invoke($next, $server)
    {
        // Continue to action first
        $next();

        // Add CORS headers after action executes
        header('Access-Control-Allow-Origin: *');
        header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE');

        if (($server['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
            http_response_code(204);
        }
    }
}
```

**Logging:**

```
class LoggingMiddleware
{
    public function __invoke($next, $server)
    {
        $method = $server['REQUEST_METHOD'] ?? 'UNKNOWN';
        $uri = $server['REQUEST_URI'] ?? '/';
        $ip = $server['REMOTE_ADDR'] ?? 'unknown';

        $start = microtime(true);

        $next();

        $duration = microtime(true) - $start;
        error_log("$method $uri from $ip - {$duration}s");
    }
}
```

**With Multiple Dependencies:**

```
class UserMiddleware
{
    public function __invoke($next, $server, $db, $logger)
    {
        // Access all dependencies passed to Router::for()
        $logger->info("Request from: {$server['REMOTE_ADDR']}");

        $next();

        $logger->info("Request completed");
    }
}

$database = new Database();
$logger = new Logger();

$routes = Router::for('GET', '/users', $_SERVER, $database, $logger)
    ->globalMiddleware(UserMiddleware::class)
    ->get('/users', [UserController::class, 'index']);

$routes->dispatch();
```

##### Complete Example

[](#complete-example)

```
use Zerotoprod\WebFramework\Router;

// Create middleware classes
class AuthMiddleware
{
    public function __invoke($next, $server)
    {
        if (!isset($_SESSION['authenticated'])) {
            http_response_code(401);
            echo json_encode(['error' => 'Unauthorized']);
            return;
        }
        $next();
    }
}

class LogMiddleware
{
    public function __invoke($next, $server)
    {
        error_log("Request: {$server['REQUEST_METHOD']} {$server['REQUEST_URI']}");
        $next();
    }
}

class AdminMiddleware
{
    public function __invoke($next, $server)
    {
        if ($_SESSION['role'] !== 'admin') {
            http_response_code(403);
            echo json_encode(['error' => 'Forbidden']);
            return;
        }
        $next();
    }
}

// Define routes with middleware
$routes = Router::for($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'], $_SERVER)
    // Global middleware for all routes
    ->globalMiddleware([
        LogMiddleware::class,
        AuthMiddleware::class
    ])

    // Public routes (global middleware still applies)
    ->get('/api/status', function() {
        echo 'OK';
    })

    // Protected admin routes
    ->get('/admin/users', [AdminController::class, 'users'])
        ->middleware(AdminMiddleware::class)
    ->post('/admin/users', [AdminController::class, 'createUser'])
        ->middleware([
            AdminMiddleware::class,
            ValidateInputMiddleware::class
        ])

    // Fallback
    ->fallback(function () {
        http_response_code(404);
        echo json_encode(['error' => 'Not Found']);
    });

// Dispatch
$routes->dispatch();
```

##### Middleware and Caching

[](#middleware-and-caching)

**⚠️ Important: Closure Limitation**

Just like routes, **middleware with closures cannot be cached** because PHP closures cannot be serialized.

✅ **Cacheable middleware:**

- Invokable classes: `AuthMiddleware::class`

❌ **Non-cacheable middleware:**

- Closures: `function ($next, $server) { ... }`

```
// ✅ Can be cached (all middleware are class names)
$routes = Router::for('', '')
    ->globalMiddleware(AuthMiddleware::class)
    ->globalMiddleware(LoggingMiddleware::class)
    ->get('/users', [UserController::class, 'index'])
        ->middleware(RateLimitMiddleware::class);

if ($routes->isCacheable()) {
    file_put_contents('cache/routes.cache', $routes->compile());
}

// ❌ Cannot be cached (contains closure middleware)
$routes = Router::for('GET', '/users', $_SERVER)
    ->globalMiddleware(function ($next, $server) {
        // Closure cannot be serialized
        echo "Logging...";
        $next();
    })
    ->get('/users', [UserController::class, 'index']);

// Will throw RuntimeException
try {
    $routes->compile();
} catch (RuntimeException $e) {
    // "Cannot compile routes with closures..."
}
```

When loading cached routes, middleware is automatically restored:

```
$compiled = file_get_contents('cache/routes.cache');
$routes = Router::for($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'], $_SERVER)
    ->loadCompiled($compiled);

// Both global and per-route middleware are restored
$routes->dispatch();
```

#### Route Caching

[](#route-caching)

Compile routes for production performance:

##### ⚠️ Important: Closure Limitation

[](#️-important-closure-limitation)

**Routes and middleware with closures cannot be cached** because PHP closures cannot be serialized.

✅ **Cacheable route types:**

- Controller arrays: `[UserController::class, 'index']`
- Invokable classes: `UserController::class`
- Class-based middleware: `AuthMiddleware::class`

❌ **Non-cacheable route types:**

- Closures: `function ($params) { echo 'Hello'; }`
- Closure middleware: `function ($next, $server) { ... }`

##### Compiling Routes

[](#compiling-routes)

```
use Zerotoprod\WebFramework\Router;

// Define routes (using cacheable formats only)
$routes = Router::for('', '')
    ->get('/users', [UserController::class, 'index'])
    ->get('/users/{id:\d+}', [UserController::class, 'show'])
    ->post('/users', [UserController::class, 'create']);

// Compile and save
$compiled = $routes->compile();
file_put_contents('cache/routes.cache', $compiled);
```

##### Checking Cacheability

[](#checking-cacheability)

```
use Zerotoprod\WebFramework\Router;

$routes = Router::for('', '')
    ->get('/users', [UserController::class, 'index'])
    ->get('/posts', function ($params) {
        echo 'Posts'; // Closure - not cacheable!
    });

if ($routes->isCacheable()) {
    file_put_contents('cache/routes.cache', $routes->compile());
} else {
    echo "Warning: Routes contain closures and cannot be cached\n";
}
```

##### Loading Cached Routes

[](#loading-cached-routes)

```
use Zerotoprod\WebFramework\Router;

// Load compiled routes from cache
$compiled = file_get_contents('cache/routes.cache');

$routes = Router::for($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'], $_SERVER)
    ->loadCompiled($compiled);

// Dispatch immediately - no route definitions needed
$routes->dispatch();
```

##### Complete Caching Example

[](#complete-caching-example)

**build-cache.php** (run once to build cache):

```
use Zerotoprod\WebFramework\Router;

$routes = Router::for('', '')
    ->get('/', 'Home')
    ->get('/users', [UserController::class, 'index'])
    ->get('/users/{id:\d+}', [UserController::class, 'show']);

if (!$routes->isCacheable()) {
    throw new Exception('Cannot build route cache: Routes contain closures.');
}

file_put_contents('cache/routes.cache', $routes->compile());
echo "✓ Route cache built successfully\n";
```

**index.php** (production entry point):

```
use Zerotoprod\WebFramework\Router;

$compiled = file_get_contents('cache/routes.cache');
$routes = Router::for($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'], $_SERVER)
    ->loadCompiled($compiled);

$routes->dispatch();
```

**Performance impact:**

- **Without caching:** Route definitions execute on every request (~100-500μs for 100 routes)
- **With caching:** Single deserialization operation (~20-50μs)
- **Speed improvement:** **5-20x faster** depending on route complexity

##### Automatic Caching (autoCache)

[](#automatic-caching-autocache)

The `autoCache()` method provides environment-aware automatic route caching:

```
use Zerotoprod\WebFramework\Router;

$routes = Router::for($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'], $_SERVER)
    ->get('/users', [UserController::class, 'index'])
    ->get('/users/{id}', [UserController::class, 'show'])
    ->autoCache(__DIR__ . '/cache/routes.cache');

// Automatically caches on first request in production environment
// Automatically loads from cache on subsequent requests
$routes->dispatch();
```

**How it works:**

1. In **production** environment (`APP_ENV=production`):
    - First request: Builds routes and writes cache file
    - Subsequent requests: Loads routes from cache automatically
2. In **local/development** environments:
    - Cache is never written or read
    - Routes are built fresh on every request

**Custom environment configuration:**

```
// Use custom environment variable
$routes->autoCache(
    'cache/routes.cache',
    'DEPLOY_ENV',           // Custom env var (default: APP_ENV)
    ['staging', 'production'] // Cache in these environments (default: ['production'])
);
```

**Benefits:**

- No manual cache management needed
- Automatically detects environment
- Creates cache directory if needed
- Safe for development (never caches in local)
- Production-optimized (automatic cache usage)

#### Method Chaining

[](#method-chaining-1)

Routes support fluent method chaining:

```
use Zerotoprod\WebFramework\Router;

$routes = Router::for($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'])
    ->get('/', function() {
        echo 'Home Page';
    })
    ->get('/about', function() {
        echo 'About Us';
    })
    ->get('/users/{id:\d+}', [UserController::class, 'show'])
        ->name('users.show')
    ->post('/users', [UserController::class, 'create'])
        ->name('users.create')
    ->fallback(function ($params) {
        http_response_code(404);
        echo '404 - Not Found';
    });

$routes->dispatch();
```

**How it works:**

- All HTTP methods (`get()`, `post()`, etc.) return the `Router` instance for chaining
- Configuration methods (`where()`, `name()`, `middleware()`) also return the `Router` instance
- The router tracks the last defined route internally to apply configurations
- Routes are stored immediately when defined

This allows you to:

1. Define routes consecutively: `->get()->get()->post()`
2. Configure individual routes: `->get()->where()->name()`
3. Mix both patterns seamlessly in a single fluent chain

#### Route Matching Behavior

[](#route-matching-behavior)

Routes use **exact** method and path matching:

```
// Case-sensitive paths
$routes->get('/Users', $action);  // Only matches /Users
$routes->get('/users', $action);  // Only matches /users

// Method must match exactly
$routes->get('/data', $action);   // Only matches GET requests
$routes->post('/data', $action);  // Only matches POST requests
```

Query strings are automatically stripped:

```
// Request: GET /search?q=test&page=2
$routes->get('/search', function ($params) {
    // This route matches!
    // Access query string via $_SERVER['QUERY_STRING']
});
```

#### Static vs Dynamic Route Priority

[](#static-vs-dynamic-route-priority)

Static routes are checked first (O(1) hash lookup), then dynamic routes (O(n) regex matching):

```
$routes->get('/users/create', function ($params) {
    echo 'Create new user form';  // This executes for /users/create
});

$routes->get('/users/{id}', function ($params) {
    echo 'Show user: ' . $params['id'];  // This executes for /users/123
});

// GET /users/create → "Create new user form" (static route wins)
// GET /users/123    → "Show user: 123" (dynamic route matches)
```

#### Performance Characteristics

[](#performance-characteristics)

The router uses a **three-level indexing system** for optimal performance:

**Level 1: Static Index (O(1))**

- Hash map: `method:path` → Route
- Perfect for exact path matches
- Most common case, fastest lookup

**Level 2: Prefix Index (O(1) + O(n))**

- Hash map: `method:prefix` → \[Routes\]
- Groups dynamic routes by common prefix
- Dramatically reduces routes to check for patterns like `/users/{id}`, `/posts/{slug}`

**Level 3: Method Index (O(n))**

- Hash map: `method` → \[Routes\]
- Fallback for complex dynamic routes without common prefixes
- Only checked if levels 1 and 2 don't match

**Performance Table:**

Route TypeExampleLookupPerformanceStatic`/users`Level 1O(1) - Hash lookupDynamic with prefix`/users/{id}`Level 2O(1) + O(n small)Dynamic no prefix`/{tenant}/{resource}`Level 3O(n) - Method filtered**Dispatch Order:**

1. **Static index** (O(1)) - Exact matches like `/users`, `/about`
2. **Prefix index** (O(1) + O(n small)) - Patterns like `/users/{id}`, `/api/posts/{slug}`
3. **Method index** (O(n)) - Complex patterns, routes without common prefixes
4. **Fallback handler** - If no route matches

**Best Practices:**

- Use static routes for hot paths (dashboards, landing pages)
- Group related dynamic routes with common prefixes (`/api/*`, `/admin/*`)
- Keep total routes under 500 for optimal prefix indexing
- Cache routes in production for best performance

#### Complete Example

[](#complete-example-1)

```
use Zerotoprod\WebFramework\Router;

class UserController {
    public function index($params) {
        echo json_encode(['users' => ['Alice', 'Bob']]);
    }

    public function show($params, $server, $db) {
        $user = $db->find('users', $params['id']);
        echo json_encode($user);
    }

    public function create($params, $server, $db) {
        $userId = $db->insert('users', $_POST);
        echo json_encode(['id' => $userId]);
    }
}

// Initialize database
$database = new Database();

// Define routes with context
$routes = Router::for($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'], $_SERVER, $database)
    ->get('/', 'Welcome to the API')
    ->get('/users', [UserController::class, 'index'])
        ->name('users.index')
    ->get('/users/{id:\d+}', [UserController::class, 'show'])
        ->name('users.show')
    ->post('/users', [UserController::class, 'create'])
        ->name('users.create')
    ->get('/posts/{year}/{month?}', function ($params) {
        $year = $params['year'];
        $month = $params['month'] ?? 'all';
        echo "Archive: $year/$month";
    })
        ->where([
            'year' => '\d{4}',
            'month' => '\d{2}'
        ])
    ->fallback(function ($params) {
        http_response_code(404);
        echo json_encode(['error' => '404 Not Found']);
    });

// Dispatch
$routes->dispatch();
```

Contributing
------------

[](#contributing)

Contributions, issues, and feature requests are welcome! Feel free to check the [issues](https://github.com/zero-to-prod/web-framework/issues) page if you want to contribute.

1. Fork the repository.
2. Create a new branch (`git checkout -b feature-branch`).
3. Commit changes (`git commit -m 'Add some feature'`).
4. Push to the branch (`git push origin feature-branch`).
5. Create a new Pull Request.

###  Health Score

29

—

LowBetter than 59% of packages

Maintenance71

Regular maintenance activity

Popularity12

Limited adoption so far

Community8

Small or concentrated contributor base

Maturity20

Early-stage or recently created project

 Bus Factor1

Top contributor holds 61.1% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

171d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/502649f05d36c87d494988bd99193a4d908d345335d99c080928a726277371f5?d=identicon)[zero-to-prod](/maintainers/zero-to-prod)

---

Top Contributors

[![zero-to-prod](https://avatars.githubusercontent.com/u/61474950?v=4)](https://github.com/zero-to-prod "zero-to-prod (11 commits)")[![github-actions[bot]](https://avatars.githubusercontent.com/in/15368?v=4)](https://github.com/github-actions[bot] "github-actions[bot] (7 commits)")

---

Tags

zero-to-prodweb framework

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/zero-to-prod-web-framework/health.svg)

```
[![Health](https://phpackages.com/badges/zero-to-prod-web-framework/health.svg)](https://phpackages.com/packages/zero-to-prod-web-framework)
```

###  Alternatives

[cakephp/cakephp

The CakePHP framework

8.8k18.5M1.6k](/packages/cakephp-cakephp)[neos/flow

Flow Application Framework

862.0M451](/packages/neos-flow)[jaxon-php/jaxon-core

Jaxon is an open source PHP library for easily creating Ajax web applications

73142.3k25](/packages/jaxon-php-jaxon-core)[contao/core-bundle

Contao Open Source CMS

1231.6M2.4k](/packages/contao-core-bundle)[slack-php/slack-app-framework

Provides a foundation upon which to build a Slack application in PHP

4933.4k1](/packages/slack-php-slack-app-framework)[yiisoft/yii-middleware

Yii Middleware

21151.3k1](/packages/yiisoft-yii-middleware)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
