devvime / modpath
A Minimal and Expressive PHP Micro Routing Framework
Requires
- catfan/medoo: ^2.2
- firebase/php-jwt: ^7.0.5
- mustache/mustache: ^3.0
- phpmailer/phpmailer: ^6.10
- symfony/cache: ^7.3
- symfony/rate-limiter: ^7.3
- vlucas/valitron: ^1.4
Requires (Dev)
- pestphp/pest: ^4.7
This package is auto-updated.
Last update: 2026-05-27 03:45:47 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
RuntimeExceptionwith a clear message) -
display_errors = Offandlog_errors = Oninphp.ini - HTTPS active (
Strict-Transport-Securityheader is already being sent) -
SecurityHeadersMiddlewareinsetGlobalMiddlewares() -
CORSconfigured with an explicit origin allowlist (not*) - Redis available for
RateLimitMiddlewareandBruteForceMiddleware -
VIEWS_DIRpoints 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 |