Dumbo
A lightweight, friendly PHP framework for HTTP.
Small framework, big ears.
composer require notrab/dumbo
<?php
use Dumbo\Dumbo;
$app = new Dumbo();
$app->get('/', function ($context) {
return $context->text('Hello Dumbo! 🐘');
});
$app->get('/peanuts/:count', function ($context) {
return $context->json([
'peanuts' => $context->req->param('count'),
]);
});
$app->run();
Pick something to build
Each of these is a few lines of Dumbo. Copy one into index.php and you're away.
A JSON API for your side project
Routes, params and JSON responses, with a 405 for anyone who tries the wrong method.
$app->get('/todos/:id', fn($c) => $c->json(
$todos->find($c->req->param('id'))
));
$app->post('/todos', fn($c) => $c->json(
$todos->create($c->req->body()), 201
));
Routing →
A home for your webhooks
Catch events from GitHub, Stripe or your own services, read the headers and payload, and queue the work for later.
$app->post('/webhooks/github', function ($c) {
$event = $c->req->header('X-GitHub-Event');
$queue->push($event, $c->req->body());
return $c->json(['received' => $event]);
});
Reading requests →
Server-rendered pages with htmx
Return HTML fragments from your routes and let htmx swap them in. Bring Blade, Twig or Latte when the pages grow.
$app->post('/posts/:id/like', function ($c) {
$posts->like($c->req->param('id'));
return $c->html(
'<button disabled>Liked</button>'
);
});
Templates →
An admin area behind a password
Put Basic auth in front of one path prefix and leave the rest of the app public.
$app->use('/admin', BasicAuth::basicAuth(
'admin:' . getenv('ADMIN_PASSWORD')
));
$app->get('/admin', fn($c) => $c->html(
'<h1>Hi boss</h1>'
));
Basic auth →
A backend for your frontend
Give a React, Vue or mobile app its own small API with CORS and bearer tokens in front.
$app->use('/api', CORS::cors([
'origin' => 'https://myapp.com',
]));
$app->use('/api', BearerAuth::bearerAuth(
getenv('API_TOKEN')
));
$app->get('/api/me', fn($c) => ['name' => 'Jamie']);
CORS and bearer auth →
A docs site, like this one
These docs are a Dumbo app that renders Markdown, and the same routes build a static copy for any CDN.
$app->get('/docs/:slug', function ($c) use ($site) {
$page = $site->find($c->req->param('slug'));
return $page
? $c->view('page', ['page' => $page])
: $c->text('Not found', 404);
});
Read the source →
Let your agent set it up#
Paste this into Claude Code, Cursor or whichever coding agent you use. It checks your PHP version, installs Dumbo, writes a starter app, runs it to make sure it responds, and then asks what you'd like to build. It also gives the agent a cheat sheet of Dumbo's API, so the code it writes uses methods that exist.
# Set up a Dumbo app
You're setting up a PHP web app with Dumbo, a small HTTP framework inspired by Hono. The source and docs live at https://github.com/notrab/dumbo.
## Steps
1. Check that `php -v` reports PHP 8.3 or newer and that `composer --version` works. If either fails, stop and tell me what to install.
2. If the current folder has no `composer.json`, run `composer init --no-interaction --name=app/app`.
3. Run `composer require notrab/dumbo`.
4. Create `index.php` with the starter app below. If `index.php` already exists, ask me before replacing it.
5. Add this to `composer.json` so `composer start` runs the app:
```json
"scripts": {
"start": [
"Composer\\Config::disableProcessTimeout",
"php -S localhost:8000 index.php"
]
}
```
6. Start the server in the background with `composer start`, then check that `curl -s localhost:8000` returns `Hello Dumbo!` and `curl -s localhost:8000/health` returns `{"ok":true}`. Stop the server afterwards.
7. Tell me it's running, show me the two routes, and ask what I'd like to build.
## Starter app
```php
<?php
require __DIR__ . '/vendor/autoload.php';
use Dumbo\Dumbo;
$app = new Dumbo();
$app->get('/', fn($context) => $context->text('Hello Dumbo!'));
$app->get('/health', fn($context) => $context->json(['ok' => true]));
$app->run();
```
## Dumbo reference
Use these APIs as written. Check the source in `vendor/notrab/dumbo/src` before using anything that isn't listed here.
### Routes
```php
$app->get('/users', $handler); // also post, put, patch, delete, head, options
$app->all('/ping', $handler); // every method
$app->get('/users/:id', $handler); // read with $context->req->param('id')
$app->get('/admin', $handler, [$mw]); // middleware for one route
$api = new Dumbo();
$api->get('/users', $handler);
$app->route('/api', $api); // mount after adding the nested app's routes
```
Unknown paths respond with 404, a known path with the wrong method responds with 405, and trailing slashes redirect to the path without one.
### Handlers
A handler receives `$context` and returns a response. Returning an array sends it as JSON.
```php
$context->req->param('id');
$context->req->query('page'); // or query() for all
$context->req->queries('tags'); // every value of ?tags[]=a&tags[]=b
$context->req->body(); // parsed JSON or form body, as an array
$context->req->header('Authorization');
$context->req->method();
$context->req->path();
$context->req->getUploadedFiles();
return $context->text('Hi', 200);
return $context->json(['ok' => true], 201);
return $context->html('<h1>Hi</h1>');
return $context->redirect('/login');
return $context->send($xml, 'application/xml');
$context->header('X-Powered-By', 'Dumbo');
$context->set('user', $user); // share values from middleware
$context->get('user');
```
### Middleware
```php
$app->use(function ($context, $next) {
// before the handler
$response = $next($context);
// after the handler
return $response;
});
$app->use('/admin', $middleware); // only for /admin and below
```
Built-in middleware is in `Dumbo\Middleware`:
```php
BasicAuth::basicAuth('user:password');
BearerAuth::bearerAuth('secret-token');
CORS::cors(['origin' => 'https://example.com']);
Csrf::csrf(['getToken' => $read, 'setToken' => $write]);
BodyLimit::bodyLimit(1024 * 1024);
Cache::cache(['type' => 'public', 'maxAge' => 3600]);
Compress::compress();
Logger::logger($psrLogger);
RequestId::requestId();
```
Helpers are in `Dumbo\Helpers`: `Cookie::get/set/delete/getSigned/setSigned`, `StaticFiles::serve(__DIR__ . '/public')`, and `JWT::sign/verify/decode`. The JWT helper needs `composer require firebase/php-jwt`.
### Errors and environments
```php
use Dumbo\HTTPException;
throw new HTTPException(404, 'Post not found');
$app->onError(fn($error, $context) => $context->json(['error' => $error->getMessage()], 500));
```
Dumbo runs in development unless `DUMBO_ENV` is set or you pass the environment to `new Dumbo('production')`. Development shows stack traces for uncaught errors, so set `DUMBO_ENV=production` when deploying.
### Templates
Dumbo has no template engine. Set a render closure in middleware, then call `view()`:
```php
$app->use(function ($context, $next) use ($twig) {
$context->render(fn($view, $data = []) => $twig->render($view, $data));
return $next($context);
});
$app->get('/', fn($context) => $context->view('home.html', ['name' => 'Dumbo']));
```
The examples folder in the repo has working apps for Twig, Blade, Latte, Smarty, htmx, JWT auth, cookies, Turso and Docker.
Agents that can fetch URLs can read the same prompt from /agent-setup.md.
Stomp your first route in about a minute
Install it with Composer, write one route and open your browser.