leafs/leaf
Leaf is a lightweight PHP micro-framework for building web apps and APIs quickly, with simple routing, middleware, database, auth, and an expressive API inspired by modern PHP tools.
Leaf is a micro-framework, not a Laravel clone: you get routing, request/response, a PDO-backed query builder, auth, validation, Blade templating and a CLI, with almost no opinion about your folder structure. Two ways in:
# 1. Bare framework — you own the structure
composer require leafs/leaf
# 2. MVC skeleton (controllers, models, views, migrations, routes/)
composer global require leafs/cli
leaf create:app my-app # or the API/bare skeletons; run `leaf list` to see them
cd my-app && leaf serve
Look here first
public/index.php (or the skeleton's index.php) — the whole bootstrap: new Leaf\App, routes, $app->run(). This is the app.routes/web.php and routes/api.php in the MVC skeleton — route definitions are grouped by file, nothing magical.config/ + .env — DB credentials, app key, debug flag. In bare Leaf you may have to wire the DB connection yourself.vendor/leafs/* — Leaf is a family of small packages (leaf, db, auth, blade, form/validator, cli). When docs are ambiguous, the source is ~200 lines and readable. Do that instead of guessing.First use case — a JSON endpoint backed by a table
<?php // public/index.php
require __DIR__ . '/../vendor/autoload.php';
$app = new Leaf\App;
$db = new Leaf\Db;
$db->connect('127.0.0.1', 'root', '', 'app');
$app->get('/notes/{id}', function ($id) use ($db) {
$note = $db->select('notes')->where('id', $id)->first();
return $note ?: response()->json(['error' => 'not found'], 404);
});
$app->post('/notes', function () use ($db) {
$body = request()->body(); // JSON or form-encoded
$db->insert('notes')->params(['title' => $body['title']])->execute();
return response()->json(['id' => $db->lastInsertId()], 201);
});
$app->run();
That loop — route → request()->body() → db()->… → response()->json() — is 80% of day-to-day Leaf work.
Route files stay flat, controllers absorb complexity. Anything beyond a few lines becomes a controller method; Leaf passes the route params as positional arguments.
$app->group('/api/v1', function () use ($app) {
$app->get('/notes', [NoteController::class, 'index']);
$app->post('/notes', [NoteController::class, 'store']);
});
Query builder first, "model" only if the skeleton gives it to you. Leaf\Db is a thin, predictable builder — reach for it before abstractions:
db()->select('notes')->where('user_id', $userId)->orderBy('created_at', 'desc')->all();
db()->update('notes')->params(['title' => $t])->where('id', $id)->execute();
db()->delete('notes')->where('id', $id)->execute();
db()->raw('SELECT count(*) AS c FROM notes WHERE user_id = ?', [$userId]);
Always push user input through params() / bindings. For anything the builder can't express (window functions, CTEs, bulk upserts), use raw() rather than fighting it. The MVC skeleton's Leaf\Model is convenient for CRUD but leaks quickly — keep it for trivial entities.
Auth: pick one mode per app. Session auth for server-rendered pages, token auth for APIs.
$auth = new Leaf\Auth;
$auth->connect('127.0.0.1', 'root', '', 'app');
$result = $auth->login('email', 'password'); // returns user + token in v3
$auth->register(['name' => $n, 'email' => $e, 'password' => $p]);
Then one middleware guards everything that needs a user — don't sprinkle auth()->user() checks through controllers.
class RequireAuth extends Leaf\Middleware
{
public function before()
{
if (!auth()->user()) {
response()->json(['error' => 'unauthenticated'], 401);
}
}
}
$app->get('/me', [RequireAuth::class, 'before'], fn () => auth()->user());
Validate at the edge, in one call, with the rule-string syntax. Leaf's validator is compact and good enough for form/API input — the trick is returning early with the errors array instead of letting bad data hit the DB.
$form = new Leaf\Form; // Leaf\Validator in newer releases
if (!$form->validate([
'title' => 'text:required',
'email' => 'email:required',
])) {
return response()->json(['errors' => $form->errors()], 422);
}
Views: Blade for HTML, JSON for APIs — don't mix. In the MVC skeleton, response()->render('notes/index', $data) renders a Blade view; in bare Leaf, wire Leaf\Blade directly (configure($viewsPath, $cachePath) then render()).
Keep deployment boring. Leaf has no build step: composer install --no-dev, point the vhost document root at public/, done. This is the framework's real advantage — it drops onto cheap shared hosting where Laravel's queue/scheduler assumptions don't.
Test at the HTTP boundary. Leaf has no first-class test harness; keep controllers thin, put logic in plain classes, and exercise those with PHPUnit. For route-level confidence, spin up leaf serve and hit endpoints with Guzzle, or invoke route callbacks directly with request() faked where the version allows it.
Leaf\Form → Leaf\Validator, auth returning a bare user vs ['user' => …, 'token' => …], middleware signature changes). Pin the exact version in composer.json, read the upgrade guide before bumping, and verify ambiguous APIs against vendor/leafs/* rather than blog posts.response()->json() (and redirect()) echo and typically halt — returning a value and calling them gives you doubled bodies or headers-already-sent warnings. Pick one: return a string/array from the handler, or call response() and stop.app(), db(), auth(), request(), response()) come from Leaf's bootstrap. They exist in the skeleton and after new Leaf\App, but if you're in a script, a queue worker, or a test, they may not be registered — instantiate the classes directly there.auth(), db(), and models all need a connection configured first; a silent "no results" is usually an unconfigured connection, not a query bug.failed to open stream on views are almost always permissions on views/cache (or storage/), not a Blade bug. Clear it when a template change doesn't appear.leaf serve is PHP's built-in server. Single-threaded, no .htaccess, dies on a blocked request. Never benchmark or ship on it; use nginx/Apache with the docroot at public/.request()->getUri()/scheme can come back as http. Normalize X-Forwarded-Proto/-For yourself in a global middleware.composer require. Budget time for hand-rolled rate limiting, queues, and payment integrations — or shell out to standalone libraries (stripe/stripe-php, phpmailer) instead of looking for Leaf wrappers.APP_DEBUG / the debug config, then read vendor/leafs/* and PHP's error log — a 500 with an empty body is usually a caught exception plus debug off.Leaf\Config for environment-driven settings, and Leaf's module/attach mechanism if you genuinely need to package a route set for reuse. You can also hand Leaf\Db an existing PDO-style connection if you need to share one with legacy code — but verify the exact method for your version in src/.How can I help you explore Laravel packages today?