A lightweight, friendly PHP framework for HTTP — Inspired by Hono.
- 🚀 Lightweight and fast
- 🧩 Middleware support, including PSR-15
- 🛣️ Flexible routing with parameters
- 🔒 Built-in security features (CSRF, JWT)
- 🍪 Cookie management
- 🔍 Request ID for tracing
- 📁 Static file serving
- 🔐 Basic and Bearer authentication
- 📝 Logging support
- 🗃️ HTTP caching
- 🔄 CORS support
- 📡 Streaming and server-sent events
- 🗜️ Response compression
- 📦 Request body size limits
- 🧬 Environment-based configuration
- ✅ Request validation
composer require notrab/dumboHere's a basic example of how it works!
<?php
require __DIR__ . '/vendor/autoload.php';
use Dumbo\Dumbo;
$app = new Dumbo();
$app->use(function ($context, $next) {
$context->set('message', 'Hello from middleware!');
return $next($context);
});
$app->get('/', function ($context) {
return $context->json([
'message' => $context->get('message'),
'timestamp' => time()
]);
});
$app->get('/users/:id', function ($context) {
$id = $context->req->param('id');
return $context->json(['userId' => $id]);
});
$app->post('/users', function ($context) {
$body = $context->req->body();
return $context->json($body, 201);
});
$app->run();See the examples directory for more quickstarts.
Keep your app in memory between requests by running it in a FrankenPHP worker:
<?php
$app = new Dumbo();
// ...routes
if (function_exists('frankenphp_handle_request')) {
while (frankenphp_handle_request(fn() => $app->run())) {
gc_collect_cycles();
}
} else {
$app->run();
}- Middleware moved from
Dumbo\HelperstoDumbo\Middleware, andCacheMiddlewareandCsrfMiddlewareare nowCacheandCsrf. BodyLimit::limit()is nowBodyLimit::bodyLimit(), andCacheMiddleware::withHeaders(...)is nowCache::cache([...])withtype,maxAgeandmustRevalidateoptions.- CORS options are camelCase:
allowMethods,allowHeaders,exposeHeadersandmaxAge. DateHelperwas removed; use PHP's date functions.firebase/php-jwtis no longer installed automatically. Require it if you useDumbo\Helpers\JWT.- Pass the environment to
new Dumbo('production')or setDUMBO_ENV.setEnvironment(),detectEnvironment(),isProduction()andisTesting()were removed, and Dumbo no longer changeserror_reporting. Theenvironmentcontext variable is now the environment name.
Dumbo is open-sourced software licensed under the MIT license.
<?php
$app->get('/users', function($context) { /* ... */ });
$app->post('/users', function($context) { /* ... */ });
$app->put('/users/:id', function($context) { /* ... */ });
$app->delete('/users/:id', function($context) { /* ... */ });
// Several methods, or every method
$app->on(['GET', 'POST'], '/form', function($context) { /* ... */ });
$app->all('/anything', function($context) { /* ... */ });<?php
$app->get('/users/:id', function($context) {
$id = $context->req->param('id');
return $context->json(['id' => $id]);
});Constrain a param with a regex, make the last ones optional, or match
anything with *. A trailing /* also matches the path without it.
<?php
$app->get('/posts/:id{[0-9]+}', fn($c) => $c->json(['id' => $c->req->param('id')]));
$app->get('/animals/:type?', fn($c) => $c->text($c->req->param('type') ?? 'all'));
$app->get('/files/*', fn($c) => $c->text('any file'));
$app->get('/wild/*/card', fn($c) => $c->text('any path in between'));FastRoute's {id:\d+} syntax works too.
Route and middleware methods return the app, so you can chain them:
<?php
$app
->use(Logger::logger($psrLogger))
->get('/', fn($c) => $c->text('Home'))
->post('/users', fn($c) => $c->json($c->req->body(), 201));Requests with a trailing slash are redirected to the path without it. GET
and HEAD get a 301; other methods get a 308 so the method and body are
kept.
<?php
$nestedApp = new Dumbo();
$nestedApp->get('/nested', function($context) {
return $context->text('This is a nested route');
});
$app->route('/prefix', $nestedApp);The nested app's middleware comes with its routes, and middleware it scopes to a path stays scoped under the new prefix. Add routes and middleware to it before mounting it.
<?php
$app->get('/', function($context) {
$path = $context->req->path();
$routePath = $context->req->routePath();
$queryParam = $context->req->query('param');
$tags = $context->req->queries('tags');
$body = $context->req->body();
$json = $context->req->json(); // [] unless the body is JSON
$form = $context->req->form(); // [] unless the body is a form
$rawBody = $context->req->text();
$url = $context->req->url();
$theme = $context->req->cookie('theme');
$userAgent = $context->req->header('User-Agent');
$psrRequest = $context->req->raw();
});<?php
return $context->json(['key' => 'value']);
return $context->text('Hello, World!');
return $context->html('<h1>Hello, World!</h1>');
return $context->redirect('/new-url');
return $context->send($csv, 'text/csv');
// Set the status for responses that don't pass one
return $context->status(201)->json($user);stream() sends the body as you write it, and streamSSE() sends
server-sent events.
The callback runs as the body is read, a write at a time, so it streams
from run() or any PSR-7 server. Middleware that reads the whole body, like
Compress and Cache, leaves streams alone.
<?php
use Dumbo\Stream\StreamWriter;
$app->get('/events', function ($context) {
return $context->streamSSE(function (StreamWriter $stream) {
for ($i = 1; !$stream->aborted() && $i <= 10; $i++) {
$stream->writeSSE(['tick' => $i], event: 'tick', id: (string) $i);
$stream->sleep(1);
}
});
});
$app->get('/export', function ($context) {
return $context->stream(function (StreamWriter $stream) {
foreach (fetchRows() as $row) {
$stream->writeln(implode(',', $row));
}
}, 'text/csv');
});<?php
$app->use(function($context, $next) {
$response = $next($context);
return $response;
});Built-in middleware lives in Dumbo\Middleware:
<?php
use Dumbo\Middleware\BasicAuth;
use Dumbo\Middleware\BearerAuth;
use Dumbo\Middleware\BodyLimit;
use Dumbo\Middleware\Cache;
use Dumbo\Middleware\Compress;
use Dumbo\Middleware\CORS;
use Dumbo\Middleware\Csrf;
use Dumbo\Middleware\Logger;
use Dumbo\Middleware\RequestId;
use Dumbo\Middleware\Validator;
$app->use(Logger::logger($psrLogger));
$app->use(RequestId::requestId());
$app->use(CORS::cors(['origin' => ['https://example.com']]));
$app->use(Compress::compress());
$app->use(BodyLimit::bodyLimit(1024 * 1024));
$app->use('/admin/*', BasicAuth::basicAuth('user:password'));
$app->use('/api', BearerAuth::bearerAuth('secret-token'));
$app->use('/blog', Cache::cache(['type' => 'public', 'maxAge' => 3600]));
$app->use('/orgs/:org/admin', $requireOrgAdmin);Validator::validator() checks the json, form, query, param,
header or cookie part of a request before your handler runs. Return a
response to reject the request, or the validated data, which the handler
reads with $context->req->valid(). Bring whichever validation library you
like.
<?php
use Dumbo\Middleware\Validator;
$app->post('/users', function ($context) {
$user = $context->req->valid('json');
return $context->json($user, 201);
}, [
Validator::validator('json', function (array $value, $context) {
if (!filter_var($value['email'] ?? '', FILTER_VALIDATE_EMAIL)) {
return $context->json(['error' => 'A valid email is required'], 400);
}
return ['email' => strtolower($value['email'])];
}),
]);Any PSR-15 middleware works anywhere
Dumbo middleware does. Request attributes it adds are available from
$context->req->raw().
<?php
$app->use(new Middlewares\ClientIp());
$app->get('/', function ($context) {
return $context->text($context->req->raw()->getAttribute('client-ip'));
});Dumbo is also a PSR-15 request handler, so $app->handle($request) works
with any PSR-7 server request.
Helpers for cookies, JWTs and static files live in Dumbo\Helpers.
StaticFiles::serve() uses the path route param when there is one, and
the full request path otherwise:
<?php
use Dumbo\Helpers\StaticFiles;
// /assets/app.css serves ./public/app.css
$app->get('/assets/:path{.+}', StaticFiles::serve(__DIR__ . '/public'));
// /static/app.css serves ./public/static/app.css
$app->get('/static/*', StaticFiles::serve(__DIR__ . '/public'));The JWT helper needs firebase/php-jwt:
composer require firebase/php-jwtShare values across middleware and handlers with $context->set() and
$context->get().
<?php
$app = new Dumbo();
// Available to every route
$app->use(function ($context, $next) {
$context->set('DB_URL', 'mysql://user:pass@localhost/mydb');
$context->set('API_KEY', 'your-secret-key');
return $next($context);
});
// Or only to routes under a given prefix
$app->use('/api', function ($context, $next) {
$context->set('DEBUG', true);
return $next($context);
});
$app->get('/api/data', function ($context) {
$apiKey = $context->get('API_KEY');
// Use $apiKey in your logic...
return $context->json(['message' => 'API key is set']);
});
$app->run();$app->request() sends a request straight to your app, without a server.
Arrays are sent as JSON.
<?php
$response = $app->request('/users?page=2');
$response = $app->request('/users', 'POST', ['Authorization' => 'Bearer token'], ['name' => 'Jamie']);
$this->assertSame(201, $response->getStatusCode());Throw an HTTPException to respond with a JSON error, or handle every
uncaught exception yourself with onError(). Requests that match no route go
to notFound(), which runs after middleware like any route handler. Call
$context->notFound() from a handler to send the same response.
<?php
use Dumbo\HTTPException;
$app->onError(function ($error, $context) {
return $context->json(['error' => $error->getMessage()], 500);
});
$app->notFound(function ($context) {
return $context->json(['error' => 'Not found'], 404);
});
$app->get('/users/:id', function ($context) {
$user = findUser($context->req->param('id'));
if (!$user) {
return $context->notFound();
}
if (!$user->active) {
throw new HTTPException(403, 'User is disabled', 'USER_DISABLED');
}
return $context->json($user);
});