I kept starting the same kind of small PHP project: a quick JSON API, a webhook receiver, a few endpoints behind a static site. Every time, Laravel or Symfony felt like a lot of scaffolding for something that only needed a handful of handlers. So I built a microframework for exactly that middle ground, and I have been building it in public with Claude Code on YouTube.
It is called Batframe, and the whole idea fits in one sentence: your method name is the route.
Extend one class, get endpoints
You extend a base class and write public methods. Each verb-prefixed method becomes an HTTP endpoint whose route is inferred from its name. There is no route file to author and nothing to register.
use Batframe\Batframe;
use Batframe\Http\Request;
use Batframe\Http\Response;
class App extends Batframe
{
public function index() // GET /
{
return view('home', ['name' => 'World']);
}
public function getUsers() // GET /users
{
return ['users' => ['ada', 'linus']]; // arrays become JSON automatically
}
public function getUser(int $id) // GET /user/{id}
{
return ['id' => $id];
}
public function postUsers(Request $request) // POST /users
{
return Response::json(['created' => $request->input('name')], 201);
}
}
(new App())->run();
That is a working app. No config, no route table, no annotations.
The routing convention
The route comes entirely from the method name and its typed parameters.
| Method | Route |
|---|---|
index() |
GET / |
getUsers() |
GET /users |
getUser(int $id) |
GET /user/{id} |
postUsers() |
POST /users |
putUser(int $id) |
PUT /user/{id} |
deleteUser(int $id) |
DELETE /user/{id} |
The rules are small enough to keep in your head:
- The method must start with an HTTP verb (
get,post,put,patch,delete,head,options). That sets the HTTP method. - The remaining PascalCase words become lowercased, slash-separated path segments. There is no auto-pluralization, so the path is literal:
getUser()is/user,getUsers()is/users. - Typed scalar params (
int,string,float,bool) become{placeholder}segments in order.intandfloatalso constrain the match, so/user/abcwill not hitgetUser(int $id). - A
Request-typed param is injected and can sit in any position. - A public method that does not start with a verb is treated as an internal helper and is not routed.
When the app class starts to fill up, related endpoints can move into traits. A verb-prefixed method composed in from a trait registers exactly like an inline one, so you can group routes by feature and keep the app class tiny.
Ergonomics without a framework tax
Return values are turned into responses for you: an array or scalar becomes JSON, a string becomes HTML, null becomes a 204, and a Response is sent as-is. When you want control, the Response helpers are fluent:
Response::json($data)->status(201)->header('X-Trace', 'abc');
Requests read the way you would expect, with input() looking through the JSON body, then the form body, then the query string:
$request->input('name', $default);
$request->bearerToken();
$request->wantsJson();
There is also a small file-based session helper and a file-based cache with per-item TTL, both of which start lazily and need no setup. Views run on BladeOne, so you get familiar Blade syntax without pulling in the rest of a framework. Drop a Blade or HTML file in the pages/ directory and it is served automatically, which makes small static sites trivial.
What it is not
Being honest about scope matters more than a feature list. Batframe intentionally leaves out a middleware pipeline, a DI container, CLI scaffolding, a PSR-7 bridge, and an attribute-based route layer. Those are on the shelf, not shipped, and the routing resolver is kept as a clean seam so an attribute layer could be added later without touching dispatch. If your project needs those things today, reach for Laravel or Symfony. Batframe is for the project that does not.
Built in public with Claude Code
The reason I am sharing it now: I am building Batframe out in the open, one feature per episode, pairing with Claude Code on camera. The sessions and caching helpers, for example, got their own episode. If you want to see how the framework is designed and tested rather than just read the finished code, the series is here:
https://www.youtube.com/playlist?list=PLSohpKg3v4pE
Try it
The fastest way in is the skeleton, which scaffolds a ready-to-run app:
composer create-project michaelyousrie/batframe-skeleton my-app
cd my-app
composer serve
Or add it to an existing project:
composer require michaelyousrie/batframe
PHP 8.1+, MIT licensed. Code and docs are on GitHub: https://github.com/michaelyousrie/batframe
If you build something small with it, or you have opinions about the routing convention, I would genuinely like to hear them.
Top comments (0)