cynchro/modux
最新稳定版本:v1.1.1
Composer 安装命令:
composer create-project cynchro/modux
包简介
A lightweight, dependency-injection-first PHP framework organized as a modular monolith.
README 文档
README
A production-ready PHP modular monolith framework. Each business domain lives in its own self-contained module. No facades, no magic statics, no hidden globals — every dependency is explicit and injected.
Best for: teams that want full control over their codebase, clear request lifecycles, and testable code without learning a large framework's conventions.
At a glance
Request → Kernel → Global pipeline (CORS, RequestSize, SecurityHeaders, Logger)
→ Route middlewares (Auth?, Admin?, Tenant?)
→ Controller (typed injection via reflection)
→ Response (always JSON, never echo+exit)
- Zero magic — no facades, no service locator calls in business code
- PSR-11 container with reflection-based autowiring and
makeWithfor parameterized resolution - PSR-3 structured logger — JSON to file or stderr, falls back silently
- Middleware pipeline — composable per-route and per-group, immutable via clone
- FormRequest — validates on construction, throws
ValidationException(422) automatically - Exception hierarchy — typed exceptions map directly to HTTP status codes
- JWT + refresh token rotation — opaque refresh tokens, per-user revocation
- Rate limiting —
CacheInterface-backed (APCu in production, Array in tests), graceful no-op - RBAC —
PermissionMiddlewarechecksroles_permisosat runtime via parameterized middleware - Event system — synchronous
EventDispatcherwithlisten()/dispatch() - Multi-tenancy — row-level isolation via
TenantMiddleware+ JWTtenant_idclaim (optional) - Versioned migrations — tracked with batch numbers, supports
rollbackandfresh - 152 unit tests, PHPStan level 6 clean, PHPCS PSR-12
Requirements
- PHP 8.2+
- MySQL 8.0+ (or any PDO-compatible database)
- Composer
Installation
git clone <repo> my-project cd my-project composer install cp .env.example .env # Edit .env — see Environment Variables section
Quick start
# 1. Configure environment cp .env.example .env # Set JWT_SECRET, DB_HOST, DB_NAME, DB_USER, DB_PASS # 2. Run migrations cd backend/src php modux migrate # 3. Start the server php -S localhost:8080 -t public/
# Login curl -X POST http://localhost:8080/auth/login \ -H "Content-Type: application/json" \ -d '{"usuario":"admin@admin.com","clave":"admin123"}'
{
"success": true,
"data": {
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGci...",
"refresh_token": "a8f3c1d9e..."
}
}
# Health check
curl http://localhost:8080/health
{
"success": true,
"data": { "status": "ok", "php": "8.2.0", "db": "ok" }
}
Project structure
├── app/
│ ├── Exceptions/ # Exception hierarchy + global JSON handler
│ ├── Helpers/ # PaginatorHelper, EmailHelper
│ ├── Http/
│ │ ├── Controllers/ # Infrastructure controllers (HealthController, LogsController)
│ │ └── Middleware/ # CorsMiddleware, AuthMiddleware, AdminMiddleware,
│ │ # TenantMiddleware, PermissionMiddleware,
│ │ # SecurityHeadersMiddleware, RequestSizeLimitMiddleware,
│ │ # RequestLoggerMiddleware
│ ├── Modules/ # Business domain modules
│ │ └── {Name}/
│ │ ├── Controllers/
│ │ ├── Repositories/
│ │ ├── Requests/ # Extend FormRequest
│ │ ├── Services/
│ │ ├── ServiceProvider.php # Optional — auto-discovered at boot
│ │ └── routes.php
│ └── Support/ # Framework core
│ ├── Cache/ # ApcuCache, ArrayCache (implement CacheInterface)
│ ├── Config.php # Static config loader (config/*.php files)
│ ├── Container.php # PSR-11 DI container with autowiring + makeWith
│ ├── DB.php # withTransaction() helper
│ ├── EventDispatcher.php # Synchronous event bus
│ ├── FormRequest.php # Validated request base class
│ ├── JWTConfig.php # JWT encode/decode/refresh helpers
│ ├── Kernel.php # HTTP kernel — creates Request, dispatches
│ ├── Logger.php # PSR-3 structured JSON logger
│ ├── LogReader.php # Reads and parses app.log
│ ├── Pipeline.php # Immutable middleware pipeline
│ ├── Job.php # Base class for queueable jobs
│ ├── JobDispatcher.php # Dispatch, claim (atomic UUID), complete, fail, retry
│ ├── RateLimiter.php # CacheInterface-backed rate limiting
│ ├── Request.php # HTTP request wrapper
│ ├── Response.php # Immutable JSON response (with getHeaders())
│ ├── Roles.php # Role constants (ADMIN, USER)
│ ├── Router.php # Route registration + dispatch + prefix groups
│ ├── ServiceProvider.php # Base provider (register/boot lifecycle)
│ ├── UUIDGenerator.php # UUID v4 generation
│ ├── Validator.php # Validation engine
│ └── Contracts/ # CacheInterface, MiddlewareInterface, ServiceProviderInterface
├── modux # CLI entry point
├── bootstrap/
│ ├── app.php # Boot sequence (9 stages)
│ └── test.php # Test bootstrap (skips HTTP dispatch)
├── config/
│ ├── app.php # App settings, trusted proxies, request size
│ ├── auth.php # JWT secret, TTL, algorithm
│ ├── cors.php # Allowed origins, methods, headers
│ ├── database.php # PDO connection config
│ ├── logging.php # Channel, driver, level, path
│ └── mail.php # SMTP settings
├── migrations/ # 0001_*.php, 0002_*.php, ...
├── public/index.php # 3-line entry point
├── seeders/
└── tests/
├── Feature/ # Full HTTP dispatch, real DB, transaction rollback
└── Unit/ # Mocked repositories, no DB
CLI — php modux
php modux make:module <Name> [--with-tenant] Scaffold a complete module
php modux make:migration <name> Create a versioned migration file
php modux make:test <Name> Generate a unit test stub
php modux migrate Run all pending migrations
php modux migrate:rollback Roll back the last migration batch
php modux migrate:fresh Rollback all + re-run all migrations
php modux routes List every registered route
php modux queue:work [--queue=X] [--sleep=N] [--once] [--timeout=N]
php modux queue:failed List failed jobs
php modux queue:retry <id> Retry a failed job
php modux queue:flush Delete all failed jobs
make:module
php modux make:module Producto
php modux make:module Factura --with-tenant # tenant-scoped repository + TenantMiddleware
Generates app/Modules/Producto/ with:
Controllers/ProductoController.php
Repositories/ProductoRepository.php (or tenant-scoped variant)
Services/ProductoService.php
Requests/CreateProductoRequest.php
Requests/UpdateProductoRequest.php
routes.php (or with TenantMiddleware)
The module is auto-discovered — bootstrap/app.php globs app/Modules/*/routes.php at boot. No manual registration needed. Optionally add app/Modules/{Name}/ServiceProvider.php — it will also be auto-discovered.
make:migration
php modux make:migration create_productos_table
# → migrations/0002_create_productos_table.php
Files are numbered sequentially. Each file returns an anonymous class with up(PDO) and down(PDO).
migrate
php modux migrate
migrated 0001_create_base_tables.php
skipped 0002_create_clientes_table.php ← already ran
1 migration(s) ran.
Tracks ran migrations in a migrations table with a batch number. Safe to run on every deploy.
migrate:rollback and migrate:fresh
php modux migrate:rollback # undo last batch php modux migrate:fresh # rollback all + re-run all (resets to clean state)
routes
php modux routes
METHOD URI HANDLER MIDDLEWARES
─────────────────────────────────────────────────────────────────────────────────
POST /auth/login AuthController@login
POST /auth/refresh AuthController@refresh
POST /auth/logout AuthController@logout AuthMiddleware
POST /auth/impersonate AuthController@impersonate Auth, Admin, Tenant
GET /clientes ClienteController@index Auth, Tenant
GET /health HealthController@check
Does not require a database connection.
Boot sequence
bootstrap/app.php boots in 9 ordered stages:
| Stage | What happens |
|---|---|
| 1 | Load .env, enforce required vars |
| 2 | Set config path |
| 3 | Set error reporting, disable display_errors |
| 4 | Build PSR-11 Container |
| 5 | Register Logger singleton + global exception handler |
| 6 | Register PDO, DB, and CacheInterface (ApcuCache) singletons |
| 7 | Register Router + Kernel singletons |
| 7.5 | Register EventDispatcher; auto-discover + boot module ServiceProviders |
| 8 | Auto-discover module routes.php files |
| 9 | Register infrastructure routes (health, logs) |
Stage 7.5 calls register() then boot() on every app/Modules/*/ServiceProvider.php that exists. This is where modules subscribe to events and override container bindings.
Creating a module
Repository
namespace App\Modules\Producto\Repositories; use PDO; use App\Exceptions\NotFoundException; class ProductoRepository { public function __construct(private PDO $pdo) {} /** @return list<array<string, mixed>> */ public function findAll(): array { return $this->pdo->query('SELECT * FROM productos') ->fetchAll(PDO::FETCH_ASSOC); } /** @return array<string, mixed> */ public function findById(int $id): array { $stmt = $this->pdo->prepare('SELECT * FROM productos WHERE id = ?'); $stmt->execute([$id]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { throw new NotFoundException('Producto', $id); } return $row; } /** @return array<string, mixed> */ public function create(array $data): array { $stmt = $this->pdo->prepare('INSERT INTO productos (nombre, precio) VALUES (?, ?)'); $stmt->execute([$data['nombre'], $data['precio']]); return $this->findById((int) $this->pdo->lastInsertId()); } }
Service
namespace App\Modules\Producto\Services; use App\Modules\Producto\Repositories\ProductoRepository; class ProductoService { public function __construct(private ProductoRepository $repository) {} public function getAll(): array { return $this->repository->findAll(); } public function get(int $id): array { return $this->repository->findById($id); } public function create(array $d): array { return $this->repository->create($d); } }
Controller
namespace App\Modules\Producto\Controllers; use App\Support\Request; use App\Support\Response; use App\Modules\Producto\Services\ProductoService; use App\Modules\Producto\Requests\CreateProductoRequest; class ProductoController { public function __construct(private ProductoService $service) {} public function index(Request $request): Response { return Response::success($this->service->getAll()); } public function show(Request $request): Response { return Response::success($this->service->get((int) $request->route('id'))); } public function create(CreateProductoRequest $request): Response { return Response::success($this->service->create($request->validated()), 201); } }
ServiceProvider
namespace App\Modules\Producto; use App\Support\ServiceProvider; use App\Modules\Producto\Repositories\ProductoRepository; use App\Modules\Producto\Services\ProductoService; use App\Modules\Producto\Controllers\ProductoController; class ProductoServiceProvider extends ServiceProvider { public function register(): void { $this->container->singleton(ProductoRepository::class, fn ($c) => new ProductoRepository($c->get(\PDO::class)) ); $this->container->singleton(ProductoService::class, fn ($c) => new ProductoService($c->get(ProductoRepository::class)) ); $this->container->singleton(ProductoController::class, fn ($c) => new ProductoController($c->get(ProductoService::class)) ); } public function boot(): void { $router = $this->container->get(\App\Support\Router::class); require __DIR__ . '/routes.php'; } }
Routing
Individual routes
// Public $router->post('/auth/login', [AuthController::class, 'login']); // With middlewares $router->get('/productos/{id}', [ProductoController::class, 'show'], [AuthMiddleware::class]); $router->delete('/productos/{id}', [ProductoController::class, 'delete'], [AuthMiddleware::class, AdminMiddleware::class]);
Route groups — share middlewares and URI prefix
// Middleware group $router->group([AuthMiddleware::class], function ($router) { $router->get('/productos', [ProductoController::class, 'index']); $router->post('/productos', [ProductoController::class, 'create']); $router->put('/productos/{id}', [ProductoController::class, 'update']); }); // Prefix group — all routes get /v1 prepended $router->group([AuthMiddleware::class], '/v1', function ($router) { $router->get('/productos', [ProductoController::class, 'index']); // → GET /v1/productos }); // Nested groups — middlewares and prefixes are merged, not replaced $router->group([AuthMiddleware::class], function ($router) { $router->group([AdminMiddleware::class], function ($router) { $router->get('/admin/roles', [AdminController::class, 'roles']); }); }); // Parameterized middleware — RBAC permission check $router->delete('/productos/{id}', [ProductoController::class, 'delete'], [ AuthMiddleware::class, PermissionMiddleware::class . ':productos.delete', ]);
Route parameters are extracted automatically and available via $request->route('id').
Controller injection
The router resolves controller method parameters by type:
| Parameter type | What gets injected |
|---|---|
Request |
The current request (with user(), tenantId(), route params already set) |
Subclass of FormRequest |
A new instance constructed from $request->all() + routeParams() — validated on construction |
| Any other class | Resolved from the container |
| Scalar (untyped) | $request->route($paramName) |
Dual-parameter pattern — use when you need both the request context (tenantId, user) and a validated FormRequest:
public function create(Request $request, CreateProductoRequest $validated): Response { $tenantId = (string) $request->tenantId(); return Response::success($this->service->create($validated->validated(), $tenantId), 201); }
Request API
// Input — priority: route params > JSON body > POST > GET $request->input('key'); $request->input('key', 'default'); $request->all(); // all merged inputs $request->only(['campo1', 'campo2']); $request->except(['_token']); // Route parameters (from URI segments like {id}) $request->route('id'); // HTTP metadata $request->method(); // 'GET', 'POST', etc. $request->uri(); // '/path/only' (no query string) $request->header('X-Custom'); $request->bearerToken(); // extracts from Authorization: Bearer <token> $request->ip(); // client IP, respects trusted proxies // Middleware-set context $request->user(); // array payload from JWT (set by AuthMiddleware) $request->tenantId(); // string (set by TenantMiddleware) // Type checks $request->isJson(); // true if Content-Type: application/json // Magic property access $request->nombre; // same as $request->input('nombre')
Response API
Response is immutable — every method returns a new instance.
// Success responses Response::success($data); // 200 Response::success($data, 201); // 201 Created // Error responses Response::error('Not allowed.', 403); // Redirect Response::redirect('/new-path', 302); // Builder pattern (immutable — each method returns a new instance) (new Response()) ->withStatus(200) ->withHeader('X-Custom', 'value') ->json(['key' => 'value']); // Inspect without sending $response->getStatus(); // int $response->getHeaders(); // array<string, string> // Send (called once by Kernel) $response->send();
Success response shape:
{ "success": true, "data": { ... } }
Error response shape (from typed exceptions):
{ "success": false, "message": "Not found." }
Validation error shape:
{
"success": false,
"message": "Validation failed.",
"errors": {
"email": ["email is required.", "email must be a valid email address."],
"precio": ["precio must be an integer."]
}
}
Request validation
Extend FormRequest — validation runs on construction and throws ValidationException (HTTP 422) automatically.
namespace App\Modules\Producto\Requests; use App\Support\FormRequest; class CreateProductoRequest extends FormRequest { protected function rules(): array { return [ 'nombre' => 'required|min:2|max:100', 'precio' => 'required|integer', 'activo' => 'boolean', 'tipo' => 'required|in:fisico,digital', 'url_foto' => 'nullable|url', 'sku' => 'nullable|regex:/^[A-Z]{2}-\d{4}$/', 'lanzado' => 'nullable|date', 'ext_id' => 'nullable|uuid', ]; } }
all() vs validated()
// Request body: {"nombre":"Mesa","precio":150,"admin":true} // Rules: {nombre, precio} $request->all() // {"nombre":"Mesa","precio":150,"admin":true} $request->validated() // {"nombre":"Mesa","precio":150} ← only declared fields
Always use validated() in business logic — it prevents mass-assignment by design.
Validation rules
| Rule | Example | Description |
|---|---|---|
required |
required |
Present and non-empty |
email |
email |
Valid email format |
min:N |
min:6 |
Minimum string length (multibyte-aware) |
max:N |
max:255 |
Maximum string length (multibyte-aware) |
integer |
integer |
Must be an integer value |
numeric |
numeric |
Must be numeric (int or float) |
boolean |
boolean |
true, false, 0, 1, '0', '1' |
string |
string |
Must be a PHP string type |
array |
array |
Must be a PHP array type |
in:a,b,c |
in:admin,user |
Must be one of the listed values |
url |
url |
Valid URL (filter_var FILTER_VALIDATE_URL) |
date |
date |
Valid date in Y-m-d format (default) |
date:format |
date:d/m/Y |
Valid date in custom format |
regex:/pattern/ |
regex:/^\d{4}$/ |
Matches the given regular expression |
uuid |
uuid |
Valid UUID v4 format |
confirmed |
confirmed |
Matches {field}_confirmation sibling |
nullable |
nullable |
Skip all rules if field is absent or empty string |
Rules are composable with |:
'email' => 'required|email|max:255', 'rol' => 'nullable|in:1,2,3',
Exceptions → HTTP responses
Throw a typed exception anywhere — the global handler converts it to JSON automatically.
throw new AuthException('Invalid credentials.'); // 401 throw new ForbiddenException('Admin only.'); // 403 throw new NotFoundException('Producto', $id); // 404 throw new ValidationException(['campo' => ['msg']]); // 422 throw new RateLimitException('Too many attempts.'); // 429 throw new DatabaseException('Query failed.'); // 500 (message hidden in prod)
| Exception | HTTP | Notes |
|---|---|---|
AuthException |
401 | Invalid/missing/revoked token |
ForbiddenException |
403 | Authenticated but not authorized |
NotFoundException |
404 | Resource or route not found |
MethodNotAllowedException |
405 | Right path, wrong HTTP method |
ValidationException |
422 | Carries a field → messages array |
RateLimitException |
429 | Too many login attempts |
DatabaseException |
500 | DB errors; message hidden when APP_DEBUG=false |
All exceptions extend AppException. Unhandled Throwable returns 500 with the exception detail hidden in production.
Middleware
| Middleware | Applied | Effect |
|---|---|---|
CorsMiddleware |
All requests | CORS headers; handles OPTIONS preflight |
RequestSizeLimitMiddleware |
All requests | Rejects bodies over app.max_request_size (default 2 MB) |
SecurityHeadersMiddleware |
All requests | X-Frame-Options, X-Content-Type-Options, Referrer-Policy, etc. |
RequestLoggerMiddleware |
All requests | Structured JSON log entry: method, URI, status, duration |
AuthMiddleware |
Protected routes | Decodes JWT, validates token is not revoked, sets $request->user() |
AdminMiddleware |
Admin routes | Requires user['rol'] === 1, throws 403 otherwise |
TenantMiddleware |
Tenant-scoped routes | Reads tenant_id from JWT payload, sets $request->tenantId() |
PermissionMiddleware |
RBAC routes | Checks roles_permisos table for the given permission key (403 if not granted) |
The global pipeline (CorsMiddleware → RequestSizeLimitMiddleware → SecurityHeadersMiddleware → RequestLoggerMiddleware) runs on every request before any route middleware.
Writing a middleware
namespace App\Http\Middleware; use App\Support\Request; use App\Support\Response; use App\Support\Contracts\MiddlewareInterface; class AuditMiddleware implements MiddlewareInterface { public function handle(Request $request, callable $next): Response { $response = $next($request); // post-processing here return $response; } }
Authentication
Login
POST /auth/login
Content-Type: application/json
{"usuario": "email@example.com", "clave": "password"}
Returns access_token (JWT) and refresh_token (opaque, stored in DB).
The JWT payload contains sub (user ID), tenant_id, and expiry. Default TTL: 86400 seconds (configurable via JWT_TTL).
Token refresh
POST /auth/refresh
Content-Type: application/json
{"refresh_token": "a8f3c1d9..."}
Issues a new access_token + refresh_token pair. The old refresh token is deleted immediately (rotation — each token is single-use).
Logout
POST /auth/logout
Authorization: Bearer <access_token>
Content-Type: application/json
{"refresh_token": "a8f3c1d9..."} ← optional, also invalidates refresh token
Impersonation (admin only)
POST /auth/impersonate
Authorization: Bearer <admin_access_token>
Content-Type: application/json
{"target_id": 42}
- Requires
AuthMiddleware + AdminMiddleware + TenantMiddleware - Admin can only impersonate users within their own tenant
- Returns a JWT signed as the target user
Rate limiting
Login attempts are tracked per username using APCu. After 5 failed attempts the account is locked for 5 minutes (RateLimitException → 429). If APCu is not installed, rate limiting is silently skipped.
Authenticated request
GET /any-protected-route
Authorization: Bearer <access_token>
AuthMiddleware decodes the JWT, verifies the token exists in the DB (revocation check), and calls $request->setUser($payload). Use $request->user() in controllers and services.
DI Container
PSR-11 compliant with reflection-based autowiring.
// Register a factory $app->bind(MyService::class, fn ($c) => new MyService($c->get(PDO::class))); // Register a singleton (resolved once, reused) $app->singleton(MyService::class, fn ($c) => new MyService($c->get(PDO::class))); // Register a pre-built instance $app->instance(\PDO::class, $existingPdo); // Resolve $service = $app->get(MyService::class); // Autowire without registration (uses reflection) $service = $app->make(MyService::class); // Autowire and inject extra scalar params into builtin constructor parameters $middleware = $app->makeWith(PermissionMiddleware::class, 'facturas.delete');
Autowiring resolves constructor parameters by type name. If no binding exists for a type, it recursively resolves the class. Scalar parameters without defaults throw ContainerException. makeWith injects additional scalars positionally into builtin-typed parameters — used internally by the Router for parameterized middlewares.
Multi-tenancy
The framework ships with row-level multi-tenancy. It is opt-in — if you don't include TenantMiddleware on a route, tenantId is never set and no tenant scoping happens.
How it works
- The
usuariostable has atenant_id CHAR(36)column (FK →tenants.id) - On login,
tenant_idis embedded in the JWT payload TenantMiddlewarereadstenant_idfrom the decoded JWT and calls$request->setTenantId()- Controllers read
$request->tenantId()and pass it down to repositories - Repositories add
AND tenant_id = ?to their queries when$tenantId !== null
// Route — add TenantMiddleware to enable scoping $router->group([AuthMiddleware::class, TenantMiddleware::class], function ($router) { $router->get('/productos', [ProductoController::class, 'index']); });
// Controller public function index(Request $request): Response { return Response::success($this->service->getAllForTenant($request->tenantId())); }
// Repository — conditional scoping public function findAll(?string $tenantId = null): array { $sql = 'SELECT * FROM productos'; $params = []; if ($tenantId !== null) { $sql .= ' WHERE tenant_id = ?'; $params[] = $tenantId; } $stmt = $this->pdo->prepare($sql); $stmt->execute($params); return $stmt->fetchAll(PDO::FETCH_ASSOC); }
Running without multi-tenancy
Simply don't add TenantMiddleware to any route. The tenant_id column in usuarios can be omitted. Repositories receive null and skip the tenant filter. No other changes needed.
Admin impersonation across tenants
An admin can only impersonate users within their own tenant. Attempting cross-tenant impersonation throws AuthException(403). Passing $adminTenantId = null skips this check (internal use only — the route always passes the real tenant ID via TenantMiddleware).
Config
Config::get('auth.jwt_secret'); // config/auth.php → jwt_secret Config::get('app.debug', false); // with default Config::get('cors.allowed_origins'); // array Config::all('database'); // entire config/database.php as array
Config files live in config/ and are plain PHP files returning arrays. Values map to env vars via $_ENV.
Logger
PSR-3 compliant. Inject via constructor:
public function __construct( private ProductoRepository $repository, private \App\Support\Logger $logger, ) {} public function delete(int $id): void { $this->logger->info('Deleting product', ['id' => $id]); $this->repository->delete($id); $this->logger->error('DB error', ['exception' => $e->getMessage()]); }
Output to storage/logs/app.log (structured JSON, one entry per line):
{"timestamp":"2026-04-25T15:30:00+00:00","level":"info","message":"Deleting product","context":{"id":42}}
Log levels (in order): debug, info, notice, warning, error, critical, alert, emergency
The minimum level is controlled by LOG_LEVEL. Messages below it are silently dropped.
If the log file cannot be written, the logger falls back to STDERR automatically — no silent failures.
Pagination
PaginatorHelper wraps any SQL query and reads page / perPage from query parameters automatically.
public function list(?string $tenantId = null): array { $sql = 'SELECT * FROM productos WHERE activo = 1'; $params = []; if ($tenantId !== null) { $sql .= ' AND tenant_id = ?'; $params[] = $tenantId; } return (new PaginatorHelper($this->pdo, $sql, $params))->getPaginatedResults(); }
Query parameters accepted:
| Param | Default | Description |
|---|---|---|
page |
1 |
Current page (1-indexed) |
perPage |
10 |
Items per page |
paginate |
true |
Set to false to return all results unpaged |
Response shape (always HTTP 200, even when results is empty):
{
"total": 42,
"cantidad_por_pagina": 10,
"pagina": 2,
"cantidad_total": 42,
"results": [...]
}
LIMIT and OFFSET are bound via PDO prepared statements. perPage and page are cast to integers.
Migrations
// migrations/0002_create_productos_table.php return new class { public function up(\PDO $pdo): void { $pdo->exec(" CREATE TABLE IF NOT EXISTS productos ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, nombre VARCHAR(255) NOT NULL, precio INT NOT NULL DEFAULT 0, tenant_id CHAR(36) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_tenant (tenant_id), CONSTRAINT fk_productos_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci "); } public function down(\PDO $pdo): void { $pdo->exec('DROP TABLE IF EXISTS productos'); } };
Testing
composer test # PHPUnit (152 tests) composer lint # phpcs PSR-12 composer analyse # phpstan level 6
Unit tests — mock repositories, no DB
class ProductoServiceTest extends UnitTestCase { private ProductoRepository $repository; private ProductoService $service; protected function setUp(): void { parent::setUp(); $this->repository = $this->createMock(ProductoRepository::class); $this->service = new ProductoService($this->repository); } public function test_throws_not_found_when_product_missing(): void { $this->repository ->method('findById') ->willThrowException(new NotFoundException('Producto', 99)); $this->expectException(NotFoundException::class); $this->service->get(99); } }
UnitTestCase provides:
setUp()— clears superglobals before each testmakeRequest(?array $user, ?string $tenantId): Request— builds a Request with pre-set user/tenant context
Feature tests — full HTTP dispatch, real DB, auto-rollback
class ProductoFeatureTest extends FeatureTestCase { public function test_create_returns_201(): void { $token = $this->loginAs('admin@admin.com', 'admin123'); $response = $this->post('/productos', [ 'nombre' => 'Mesa', 'precio' => 150, ], $token); $this->assertTrue($response['success']); $this->assertSame(201, $this->lastStatus()); } }
Each feature test wraps its DB operations in a transaction that rolls back in tearDown().
Events
EventDispatcher provides a synchronous in-process event bus. Inject it anywhere via the container.
// Subscribe in ServiceProvider::boot() $dispatcher->listen('usuario.created', function (array $payload): void { // send welcome email, log audit trail, etc. // $payload = ['id' => 42, 'email' => 'user@example.com'] }); // Dispatch from a Service $this->dispatcher->dispatch('usuario.created', [ 'id' => $id, 'email' => $data['email'], ]); // Check if anyone is listening $dispatcher->hasListeners('usuario.created'); // bool
Events are synchronous — the caller waits for all listeners to finish. For fire-and-forget behaviour wrap the listener body in a try/catch.
RBAC — permission-based access control
Assign permission keys to roles via the roles_permisos table (each row links a rol_id to a permiso_id). Use PermissionMiddleware on routes that require a specific permission:
use App\Http\Middleware\PermissionMiddleware; $router->group([AuthMiddleware::class, TenantMiddleware::class], function ($router) { $router->get('/facturas', [FacturaController::class, 'index']); $router->post('/facturas', [FacturaController::class, 'create'], [PermissionMiddleware::class . ':facturas.write']); $router->delete('/facturas/{id}', [FacturaController::class, 'delete'], [PermissionMiddleware::class . ':facturas.delete']); });
The middleware throws ForbiddenException (403) if the authenticated user's role does not have the requested permission. AdminMiddleware still covers simple admin-only gates; use PermissionMiddleware for fine-grained per-operation control.
Database transactions
App\Support\DB wraps operations in a PDO transaction with automatic rollback on any exception:
class FacturaService { public function __construct( private FacturaRepository $facturas, private LineaRepository $lineas, private DB $db, ) {} public function create(array $data): array { return $this->db->withTransaction(function () use ($data) { $factura = $this->facturas->create($data); foreach ($data['lineas'] as $linea) { $this->lineas->create($factura['id'], $linea); } return $factura; }); } }
Inject DB in any service; the container auto-wires it with the registered PDO singleton.
Job queue
DB-backed async queue. Jobs are stored in a jobs table and processed by a worker process. Multiple workers can run in parallel — claiming is done with an atomic UUID UPDATE.
Defining a job
namespace App\Modules\Notificaciones\Jobs; use App\Support\Container; use App\Support\Job; class SendWelcomeEmailJob extends Job { public string $email = ''; public string $name = ''; public string $queue = 'emails'; // override the default queue public function handle(Container $container): void { $container->get(MailService::class)->sendWelcome($this->email, $this->name); } }
Public properties (except the framework-reserved queue, maxAttempts, delaySeconds) are serialized as JSON payload in the DB. Service dependencies are resolved from the Container when handle() runs.
Dispatching
// Inject JobDispatcher in any service constructor public function __construct(private JobDispatcher $dispatcher) {} $job = new SendWelcomeEmailJob(); $job->email = $data['email']; $job->name = $data['nombre']; $this->dispatcher->dispatch($job); // Dispatch with a delay (seconds before the job becomes available) $job->delaySeconds = 300; $this->dispatcher->dispatch($job);
Running the worker
php modux queue:work # process 'default' queue, sleep 3s between polls php modux queue:work --queue=emails # process a specific queue php modux queue:work --queue=emails --sleep=5 # custom sleep interval php modux queue:work --once # process one job then exit (useful for cron) php modux queue:work --timeout=10 # release jobs stuck > 10 minutes
SIGINT / SIGTERM (Ctrl-C) triggers a graceful shutdown — the worker finishes the current job before stopping.
For production, manage the worker with supervisord or systemd so it restarts automatically if it crashes.
Failed jobs
On failure the job is retried up to maxAttempts times (default 3) with exponential back-off: 2^attempts seconds between retries. After the last attempt the job row is marked status = 'failed' with the full error message stored.
php modux queue:failed # list all failed jobs php modux queue:retry 42 # reset job #42 to 'pending' so the worker picks it up again php modux queue:flush # delete all failed jobs
jobs table schema
| Column | Type | Description |
|---|---|---|
id |
INT AUTO_INCREMENT | Primary key |
queue |
VARCHAR(100) | Queue name |
payload |
MEDIUMTEXT | JSON-serialized class + data |
attempts |
INT | How many times the worker tried |
max_attempts |
INT | Copied from Job at dispatch time |
status |
ENUM | pending, running, failed |
available_at |
DATETIME | When the job becomes eligible (supports delay) |
reserved_at |
DATETIME | When a worker claimed it |
reserved_by |
CHAR(36) | UUID of the worker that claimed it (atomic lock) |
failed_at |
DATETIME | When the job was finally marked failed |
error |
TEXT | Exception message + trace |
Health check
GET /health
Returns 200 when DB is reachable, 503 when degraded:
{ "success": true, "data": { "status": "ok", "php": "8.2.0", "db": "ok" } }
{ "success": true, "data": { "status": "degraded", "php": "8.2.0", "db": "unreachable" } }
Use this endpoint for load balancer health probes, uptime monitors, and deploy scripts.
Environment variables
Copy .env.example → .env. Required at boot (missing variables throw immediately):
| Variable | Description |
|---|---|
JWT_SECRET |
Min 32 chars. Generate: php -r "echo bin2hex(random_bytes(32));" |
DB_HOST |
Database host |
DB_NAME |
Database name |
DB_USER |
Database user |
DB_PASS |
Database password |
Optional:
| Variable | Default | Description |
|---|---|---|
APP_ENV |
local |
local / production |
APP_DEBUG |
false |
Expose exception details in JSON responses |
JWT_TTL |
86400 |
Access token lifetime in seconds |
JWT_REFRESH_TTL |
604800 |
Refresh token lifetime in seconds (7 days) |
JWT_ALGO |
HS256 |
JWT signing algorithm |
DB_PORT |
3306 |
Database port |
LOG_CHANNEL |
file |
file or stderr |
LOG_LEVEL |
debug |
Minimum log level to write |
CORS_ALLOWED_ORIGINS |
(none) | Comma-separated list of allowed origins |
MAIL_HOST, MAIL_PORT, MAIL_USER, MAIL_PASS, MAIL_FROM |
— | SMTP credentials for EmailHelper |
Why not Laravel?
| Modux | Laravel | |
|---|---|---|
| Runtime dependencies | ~5 | 30+ |
| Request lifecycle | 5 files | 50+ files |
| DI | Explicit constructor injection | Facades + service locator |
| Magic | None | Auth::user(), DB::table(), Cache::get(), ... |
| ORM | Raw PDO (you control every query) | Eloquent |
| Queue / Events | DB-backed async queue + synchronous EventDispatcher |
Full async queue + broadcasting |
| Validation rules | 16 essential | 50+ |
| Learning curve | Read the source, understand everything | Learn the framework conventions |
| Suited for | Controlled APIs, internal tools, multi-tenant SaaS | Full-featured web apps |
If you need Eloquent, queues, broadcasting, or an ecosystem of packages — use Laravel.
If you want to understand exactly what happens on every line of every request — use this.
License
MIT
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 5
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-05-11