devvime/modpath

A Minimal and Expressive PHP Micro Routing Framework

Maintainers

Package info

github.com/devvime/modpath-php

pkg:composer/devvime/modpath

Statistics

Installs: 61

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v1.0.32 2026-05-27 03:44 UTC

README

A Minimal and Expressive PHP Micro Routing Framework

Installation

composer require devvime/modpath

Initial Setup

require dirname(__DIR__) . '/vendor/autoload.php';

use ModPath\Router\Router;
use ModPath\Middleware\SecurityHeadersMiddleware;
use ModPath\Middleware\CorsMiddleware;
use ModPath\Middleware\RateLimitMiddleware;
use ModPath\Middleware\RequestSizeLimitMiddleware;
use App\Controllers\UserController;
use App\Controllers\ProductController;

$router = new Router();

$router->setGlobalMiddlewares([
    SecurityHeadersMiddleware::class,
    CorsMiddleware::class,
    RateLimitMiddleware::class,
    RequestSizeLimitMiddleware::class,
]);

$router->registerRoutes([
    UserController::class,
    ProductController::class,
]);

$router->dispatch();

Unhandled exceptions in controllers are caught automatically, logged via error_log, and returned as a generic 500 Internal Server Error — stack traces are never exposed to the client.

Controllers

Use #[Controller] to define a route prefix and optional middleware applied to all methods in the class. Use #[Route] on each method to define the HTTP path and method.

namespace App\Controllers;

use ModPath\Http\Request;
use ModPath\Http\Response;
use ModPath\Attribute\Route;
use ModPath\Attribute\Controller;
use ModPath\Attribute\Middleware;
use ModPath\Contract\ControllerInterface;
use App\Middleware\AuthMiddleware;

#[Controller(path: '/users', middleware: AuthMiddleware::class)]
class UserController implements ControllerInterface
{
    #[Route(path: '', method: 'GET')]
    public function index(Request $request, Response $response): void
    {
        $response->json(['message' => 'Users list']);
    }

    #[Route(path: '/{id:int}', method: 'GET')]
    public function show(Request $request, Response $response): void
    {
        $response->json(['id' => $request->params['id']]);
    }

    #[Route(path: '', method: 'POST')]
    public function store(Request $request, Response $response): void
    {
        $response->json(['message' => 'User created'], 201);
    }

    #[Route(path: '/{id:int}', method: 'PUT')]
    public function update(Request $request, Response $response): void
    {
        $response->json(['message' => 'User updated']);
    }

    #[Route(path: '/{id:int}', method: 'DELETE')]
    public function destroy(Request $request, Response $response): void
    {
        $response->json(['message' => 'User deleted']);
    }
}

Route Parameters

Parameters are defined with {name} or {name:type}.

Type Pattern PHP type
string [^/]+ (default) string
int \d+ int
uuid [0-9a-fA-F\-]{36} string
slug [a-z0-9\-]+ string
#[Route(path: '/posts/{slug:slug}', method: 'GET')]
public function show(Request $request, Response $response): void
{
    $slug = $request->params['slug']; // string
}

#[Route(path: '/orders/{id:int}', method: 'GET')]
public function order(Request $request, Response $response): void
{
    $id = $request->params['id']; // int (cast automatically)
}

Request

$request->params;   // URL parameters — typed according to route definition
$request->body;     // Parsed request body (stdClass|array|null)
$request->query;    // Query string ($_GET)
$request->headers;  // HTTP request headers only (keys: UPPER-CASE-HYPHEN)

$request->body is parsed automatically based on Content-Type:

Content-Type Result
application/json stdClass or null
application/x-www-form-urlencoded array or null
multipart/form-data array or null
(unknown / empty) null

php://input is read at most once per request, regardless of how many times Request is instantiated.

Response

$response->json(['key' => 'value']);            // 200 JSON
$response->json(['error' => 'Not found'], 404); // JSON with explicit status
$response->status(204);                         // Status only (no body)
$response->send('plain text');                  // Plain text output
$response->render('templates.index', $data);    // Render template
$response->redirect('/login');                  // Redirect (terminates)
$response->header('X-Custom', 'value');         // Set arbitrary header

Middleware

Implement MiddlewareInterface. handle() must return true to continue the request. To block, send a response and exit.

namespace App\Middleware;

use ModPath\Http\Request;
use ModPath\Http\Response;
use ModPath\Contract\MiddlewareInterface;
use ModPath\Helpers\Token;

class AuthMiddleware implements MiddlewareInterface
{
    public function handle(Request $request, Response $response): bool
    {
        try {
            Token::decode(Token::get());
        } catch (\Exception $e) {
            $response->json(['error' => 'Unauthorized'], 401);
            exit;
        }

        return true;
    }
}

Apply per-method with #[Middleware], or to all methods via #[Controller(middleware: ...)]. Multiple middleware on the same method execute in declaration order.

#[Route(path: '', method: 'POST')]
#[Middleware(AuthMiddleware::class)]
public function store(Request $request, Response $response): void { ... }

Guards

#[Guard] works identically to #[Middleware] but is semantically reserved for authorization (roles, permissions). Keeping auth and authorization separate improves readability.

#[Route(path: '/admin/users', method: 'DELETE')]
#[Guard(AdminGuard::class)]
public function destroy(Request $request, Response $response): void { ... }

DTO Validation

Extend Dto, declare $allowed fields and $rules, then attach with #[Dto].

namespace App\Dto;

use ModPath\Dto\Dto;

class CreateUserDto extends Dto
{
    public array $allowed = ['name', 'email', 'password'];

    public array $rules = [
        'name'     => [['required'], ['lengthMin', 3]],
        'email'    => [['required'], ['email']],
        'password' => [['required'], ['lengthMin', 8]],
    ];
}
use ModPath\Attribute\Dto;
use App\Dto\CreateUserDto;

#[Route(path: '', method: 'POST')]
#[Dto(CreateUserDto::class)]
public function store(Request $request, Response $response): void
{
    // Body is guaranteed valid and contains only allowed fields
    $name = $request->body->name;
}

Validation failure sends 422 Unprocessable Entity and halts the request. Unknown fields are also rejected.

Helpers

Env

Safe access to environment variables. Throws RuntimeException with a clear message if a required variable is absent, instead of crashing with a generic PHP notice.

use ModPath\Helpers\Env;

$value   = Env::get('OPTIONAL_VAR', 'default');  // returns default if absent
$secret  = Env::require('SECRET');                // throws if absent or empty
Env::validate(['DB_HOST', 'DB_NAME', 'DB_USER']); // throws listing all missing vars

Token (JWT)

use ModPath\Helpers\Token;

$token   = Token::encode(['user_id' => 1, 'role' => 'admin']); // payload must be an array
$payload = Token::decode($token);  // throws on invalid or expired token
$raw     = Token::get();           // reads Bearer from Authorization header; throws on missing/malformed

Requires SECRET in the environment.

Database

Wraps Medoo with a single connection per process. Validates required environment variables on first connection.

use ModPath\Helpers\Database;

$db = new Database();
$db->connect()->select('users', ['id', 'name'], ['active' => 1]);

Required environment variables:

DATABASE_TYPE=mysql
DATABASE_NAME=mydb
DATABASE_SERVER=127.0.0.1
DATABASE_USER=root
DATABASE_PASSWORD=secret
DATABASE_PORT=3306

Repository

Extend Repository for pagination, find, create, update, and delete out of the box.

namespace App\Repositories;

use ModPath\Helpers\Repository;

class UserRepository extends Repository
{
    protected string $table = 'users';
    protected array $fields = ['id', 'name', 'email', 'created_at'];
}
$repo = new UserRepository();

$result = $repo->getAll(filters: ['active' => 1], page: 2, perPage: 20);
// Returns: ['data' => [...], 'total' => 80, 'page' => 2, 'per_page' => 20, 'last_page' => 4, 'pages' => [...]]

$rows = $repo->getById(5);
$user = $repo->find(filter: ['email' => 'ana@example.com']);
$id   = $repo->create(['name' => 'Ana', 'email' => 'ana@example.com']);
$repo->update(5, ['name' => 'Ana Lima']);
$repo->delete(5);

Model

Extend Model for a static factory that maps an array to constructor parameters.

namespace App\Models;

use ModPath\Helpers\Model;

class User extends Model
{
    public function __construct(
        public readonly int $id,
        public readonly string $name,
        public readonly string $email,
    ) {}
}

$user   = User::create(['id' => 1, 'name' => 'Ana', 'email' => 'ana@example.com']);
$fields = Model::fields(User::class); // ['id', 'name', 'email']

Message

Shorthand for a JSON status/message response.

use ModPath\Helpers\Message;

Message::send(200, 'Operation successful');
Message::send(403, 'Forbidden');

Sanitizer

Clean user input before processing or storing.

use ModPath\Helpers\Sanitizer;

$name  = Sanitizer::string($request->body->name);    // trim + htmlspecialchars
$email = Sanitizer::email($request->body->email);    // sanitize email characters
$age   = Sanitizer::int($request->body->age);        // cast to int
$price = Sanitizer::float($request->body->price);    // cast to float
$url   = Sanitizer::url($request->body->url);        // sanitize URL
$bio   = Sanitizer::stripTags($request->body->bio);  // strip HTML tags
$data  = Sanitizer::array((array) $request->body);   // sanitize all strings recursively

Rate Limiting

Direct helper for use outside middleware. Requires Redis.

use ModPath\Helpers\RateLimit;

// 10 requests per 60 seconds, keyed by IP
RateLimit::execute(time: 60, maxRequests: 10);

// Custom identifier (e.g., authenticated user ID)
RateLimit::execute(identifier: (string) $userId, time: 3600, maxRequests: 100);

Sends 429 Too Many Requests with a Retry-After header and halts execution when the limit is exceeded. Falls back to allowing requests if Redis is unavailable.

Mailer

Wraps PHPMailer with SMTP via environment variables.

use ModPath\Helpers\Mailer;

$mailer = new Mailer();
$mailer->send([
    'subject'    => 'Welcome!',
    'msgHTML'    => '<h1>Hello, Ana</h1>',
    'recipients' => [
        ['email' => 'ana@example.com', 'name' => 'Ana'],
    ],
]);

Required environment variables:

EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_USER=no-reply@example.com
EMAIL_PASSWORD=secret

View (Mustache)

Renders Mustache templates from the VIEWS_DIR directory.

use ModPath\Helpers\View;

View::render('emails/welcome', ['name' => 'Ana']); // echoes output
$html = View::get('emails/welcome', ['name' => 'Ana']); // returns string

Security

ModPath ships with a set of security middlewares. Apply them globally via setGlobalMiddlewares() — they run on every request, including unmatched routes.

SecurityHeadersMiddleware

Sets HTTP security headers on every response. Extend to customize.

Header Default value
X-Content-Type-Options nosniff
X-Frame-Options DENY
X-XSS-Protection 1; mode=block
Referrer-Policy strict-origin-when-cross-origin
Content-Security-Policy default-src 'self'
Permissions-Policy geolocation=(), microphone=(), camera=()
Strict-Transport-Security max-age=31536000; includeSubDomains
X-Powered-By (removed)
class AppSecurityHeaders extends SecurityHeadersMiddleware
{
    protected string $csp = "default-src 'self'; script-src 'self' https://cdn.example.com";
}

RateLimitMiddleware

Limits requests per IP using Redis. Responds 429 + Retry-After when exceeded.

class ApiRateLimit extends RateLimitMiddleware
{
    protected int $maxRequests = 30;
    protected int $time = 60; // seconds
}

Default: 60 requests / 60 seconds / IP.

CorsMiddleware

Handles preflight OPTIONS requests and sets CORS headers. Validates and sanitizes the Origin header to prevent header injection.

class AppCors extends CorsMiddleware
{
    protected string|array $allowedOrigins = ['https://app.example.com'];
    protected bool $allowCredentials = true;
}

allowCredentials: true requires a specific origin (not *).

BruteForce Protection

Apply #[Guard(BruteForceMiddleware::class)] to sensitive routes (login, password reset). After N failed attempts from the same IP, the route returns 429 for a configurable lockout period.

use ModPath\Helpers\BruteForce;
use ModPath\Middleware\BruteForceMiddleware;

#[Route(path: '/login', method: 'POST')]
#[Guard(BruteForceMiddleware::class)]
public function login(Request $request, Response $response): void
{
    $ip = $_SERVER['REMOTE_ADDR'];
    $user = $this->userRepo->find(filter: ['email' => $request->body->email]);

    if (!$user || !password_verify($request->body->password, $user['password'])) {
        BruteForce::failed($ip);
        $response->json(['error' => 'Invalid credentials'], 401);
        return;
    }

    BruteForce::reset($ip);
    $response->json(['token' => Token::encode(['id' => $user['id']])]);
}

Extend to adjust limits:

class LoginGuard extends BruteForceMiddleware
{
    protected int $maxAttempts  = 3;
    protected int $decaySeconds = 1800; // 30 min
}

Default: 5 attempts, 15-minute lockout. Requires Redis.

RequestSizeLimitMiddleware

Rejects payloads above the configured limit before the body is processed. Responds 413 Payload Too Large.

class TightSizeLimit extends RequestSizeLimitMiddleware
{
    protected int $maxBytes = 512_000; // 500 KB
}

Default: 2 MB.

Path Traversal Protection

Template and view paths are validated against [a-zA-Z0-9._\-\/] before touching the filesystem. Any path containing .. or invalid characters throws InvalidArgumentException.

Template Engine

Response::render() uses a custom template engine with auto-escaped output. Templates are .php files stored in VIEWS_DIR.

Variables

<p>{{ $name }}</p>

Output is wrapped in htmlspecialchars() automatically.

Conditions

<if($role == 'admin')>
    <p>Admin panel</p>
<elseif($role == 'editor')>
    <p>Editor panel</p>
<else/>
    <p>Access denied</p>
</if>

Loops

<loop($users as $user)>
    <li>{{ $user['name'] }}</li>
</loop>

For

<for($i = 1; $i <= 5; $i++)>
    <p>Item {{ $i }}</p>
</for>

Includes

<include('partials/header')/>
<include('partials/footer')/>

Included templates are compiled and inlined at render time.

Syntax Reference

Feature Template syntax PHP equivalent
Output {{ $name }} <?= htmlspecialchars($name) ?>
If <if($a > $b)> ... </if> <?php if ($a > $b): ?> ... <?php endif; ?>
Elseif <elseif($a == $b)> <?php elseif ($a == $b): ?>
Else <else/> <?php else: ?>
Loop <loop($items as $item)> ... </loop> <?php foreach ($items as $item): ?> ... <?php endforeach; ?>
For <for($i = 0; $i < 10; $i++)> ... </for> <?php for ($i = 0; $i < 10; $i++): ?> ... <?php endfor; ?>
Include <include('path/to/partial')/> (compiled and inlined)

Testing

ModPath ships with a Pest test suite covering all framework components that do not require external services (database, Redis, SMTP).

Running the tests

composer require --dev   # installs pestphp/pest if not present
./vendor/bin/pest

Test coverage

Suite File What is tested
Attribute tests/Unit/Attribute/AttributeTest.php Route, Controller, Middleware, Guard, Dto constructors
Core tests/Unit/Core/ContainerTest.php DI resolution, singletons, defaults, nullable params, circular deps
Dto tests/Unit/Dto/DtoTest.php Field allowlist, rule validation, handle() happy path
Env tests/Unit/Helpers/EnvTest.php get, require, validate — present, missing, empty values
Model tests/Unit/Helpers/ModelTest.php fields() introspection, create() factory
Sanitizer tests/Unit/Helpers/SanitizerTest.php All sanitizer methods including recursive array sanitization
Token tests/Unit/Helpers/TokenTest.php JWT encode/decode round-trip, Bearer header extraction, error paths
Request tests/Unit/Http/RequestTest.php Params, query, headers, JSON/form body parsing
Response tests/Unit/Http/ResponseTest.php JSON output, status code, plain text send
MiddlewareManager tests/Unit/Middleware/MiddlewareManagerTest.php verify() dispatch, getMiddlewares() prefix merging
RouterParams tests/Unit/Router/RouterParamsTest.php Regex generation for all param types, typed param extraction
View tests/Unit/View/ViewTest.php Template compilation ({{ }}, <if>, <loop>, <for>, escaping, path traversal guard)

Components excluded from unit tests (require external services): Database, Repository, BruteForce, RateLimit, Mailer.

Writing application tests

use ModPath\Http\Request;
use ModPath\Http\Response;

it('returns 200 with user data', function () {
    $request          = new Request(['id' => 42]);
    $request->body    = ['name' => 'Alice'];
    $response         = new Response();

    ob_start();
    (new UserController())->show($request, $response);
    $output = ob_get_clean();

    $data = json_decode($output, true);
    expect($data['id'])->toBe(42);
});

Production Checklist

  • All required environment variables are set (missing variables throw RuntimeException with a clear message)
  • display_errors = Off and log_errors = On in php.ini
  • HTTPS active (Strict-Transport-Security header is already being sent)
  • SecurityHeadersMiddleware in setGlobalMiddlewares()
  • CORS configured with an explicit origin allowlist (not *)
  • Redis available for RateLimitMiddleware and BruteForceMiddleware
  • VIEWS_DIR points to a directory without external write access
  • Uploaded files never stored inside VIEWS_DIR

Required environment variables

Variable Used by
SECRET Token
DATABASE_TYPE, DATABASE_NAME, DATABASE_SERVER, DATABASE_USER, DATABASE_PASSWORD, DATABASE_PORT Database
EMAIL_HOST, EMAIL_PORT, EMAIL_USER, EMAIL_PASSWORD Mailer
VIEWS_DIR Response::render(), View