# Leaf 5 - llms.txt
> A concise reference for AI assistants building Leaf PHP v5 applications.
> When a feature is not covered here, tell the user instead of guessing.
---
## What is Leaf 5?
Leaf 5 is the next generation of Leaf PHP. It is **not** a different framework - it is one system with three entry points. You pick a starting point and grow without switching tools, rewriting code, or changing ecosystems.
**Requires PHP 8.2+** — framework, modules, and tooling (Alchemy, Sprout) alike.
**Leaf UI is sunset.** Never suggest Leaf UI (leafs/ui, reactive PHP components) - it is archived. For frontends use Blade, scaffolds, or Inertia + React/Vue/Svelte (https://ui.leafphp.dev).
---
## Known Footguns (read before generating code)
- Roles are additive: `assign()` appends. Change a role: `unassign($user->roles())` then `assign($new)`.
- `$user->get()` hides id/password/roles. API shape: `[...$user->get(), 'roles' => $user->roles()]`.
- `text` rule = letters+spaces only. Passwords/free-form = `string`.
- Auth middleware defaults render HTML. APIs override each to JSON (401/403 via `auth()->middleware(...)`).
- `_env()` caches per process; `_envUncached()` reads live.
- Prefer Leaf functions over hand-rolled code: module method → `leaf install` → scaffold → only then custom, with the why recorded in `.leaf/CONTEXT.md`.
---
## Entry Points (Critical - get this right first)
| Type | Flag | Structure | Use For |
|-------------|-------------|------------------------------|----------------------------------|
| Basic app | `--lite` | Single `index.php` | Prototypes, scripts, simple APIs |
| Full-stack MVC | `--mvc` | Full `app/` structure + views | Web apps, dashboards, fullstack |
| API MVC | `--api` | `app/` without views | REST APIs, microservices |
| Console | `--console` | Console app via Seedling | CLI tools |
```bash
leaf create my-app --mvc
leaf create my-app --lite
leaf create my-app --api
```
**Adding views/frontends:** `leaf view:install --blade|--react|--vue|--svelte|--tailwind|--vite` sets up view engines and frontend tooling in MVC apps. For a lite app that needs views, run `leaf up` first to scale into MVC, then `view:install`.
**NEVER mix patterns.** Do not add `app/controllers/` to a Basic app. Do not use raw `index.php` routing in an MVC app.
---
## Project Structure
### Basic (`--lite`)
```
index.php ← all routes go here
vendor/
.env
.env.example
```
### MVC (`--mvc`)
```
app/
controllers/
Controller.php ← base controller, pre-generated, do not edit
database/ ← YAML schema files, one per table
models/
Model.php ← base model, pre-generated, do not edit
User.php
routes/
_app.php ← example route partial, auto-loaded
index.php ← autogenerated boilerplate, do not edit
views/
public/
index.php ← entry point, autogenerated, never edit
.htaccess
vendor/
.env ← auto-copied from .env.example on create
.env.example
```
### API (`--api`) - same as MVC but no `views/`
**Important notes on structure:**
- Every preset (lite, MVC, API, console) ships `AGENTS.md` and `.leaf/CONTEXT.md` (leaf.context v1 shared agent memory). Read `.leaf/CONTEXT.md` before working; write useful knowledge back when done.
- `public/index.php` is the app entry point. It is autogenerated boilerplate - you will never need to edit it.
- `app/routes/index.php` is also autogenerated. It handles 404/500 handlers, global middleware, and app-wide setup. Only modify it when you need those things - otherwise leave it alone.
- `app/controllers/Controller.php` and `app/models/Model.php` are base classes. Never edit them. Extend them in your own classes.
- `.env` is automatically copied from `.env.example` when you run `leaf create`. You do not need to copy it manually.
- **No `leaf.config.php`** - config goes in `.env`.
- **No `config/` folder** - unlike Laravel or older Leaf versions, there is no `config/` directory.
---
## Routing
### Basic app - routes in `index.php`
### MVC app - routes in `app/routes/` as partials
**Route partials** are files in `app/routes/` that start with `_` (e.g. `_posts.php`, `_auth.php`). Leaf MVC automatically loads all of them after `app/routes/index.php`. You never need to manually require them.
```php
// Basic syntax - no square brackets needed for simple routes
app()->get('/home', function () { /* ... */ });
app()->post('/users', function () { /* ... */ });
app()->put('/users/{id}', function ($id) { /* ... */ });
app()->delete('/users/{id}', function ($id) { /* ... */ });
// MVC: point to a controller
app()->get('/posts', 'PostsController@index');
app()->get('/posts/{slug}', 'PostsController@show');
app()->post('/posts', 'PostsController@store');
// Only use [...] when you need route parameters (middleware, name, module keys)
app()->get('/dashboard', ['middleware' => 'auth.required', 'DashController@index']);
app()->get('/login', ['middleware' => 'auth.guest', 'AuthController@index']);
app()->get('/home', ['name' => 'home', function () { /* ... */ }]);
// The array format is: ['key' => 'value', ..., handler]
// handler is always last - either a closure or 'Controller@method' string
// Groups
app()->group('/admin', ['middleware' => 'auth.required', function () {
app()->get('/', 'AdminController@index');
app()->get('/users', 'AdminController@users');
}]);
// Resource routes (auto-named: photos.index, photos.show, photos.edit, ...)
app()->resource('/photos', 'PhotosController');
app()->apiResource('/photos', 'PhotosController');
// Named groups: group name prefixes every named route inside (nested groups compose)
app()->group('/admin', ['name' => 'admin', function () {
app()->get('/dashboard', ['name' => 'dashboard', 'AdminController@dashboard']); // -> admin.dashboard
app()->resource('/users', 'UsersController'); // -> admin.users.index, admin.users.show, ...
}]);
// URL from a route name
app()->route('admin.users.show', ['id' => 5]); // /admin/users/5
// Match multiple methods
app()->match('GET|POST', '/path', function () { /* ... */ });
// Named route redirect
response()->redirect(['home']);
```
**Style:** return responses. Single-expression handlers use arrow functions: `app()->get('/', fn () => response()->json([...]));`. Multi-statement closures and controller methods end with `return response()->...` — never call `response()` without returning it.
**Critical:** Never call `app()->run()` in an MVC app. The framework handles it. Only call it in Basic (`--lite`) apps.
---
## Controllers (MVC)
Controllers live in `app/controllers/`. They extend the base `Controller` class which is already set up in `app/controllers/Controller.php` - no namespace import needed.
```php
get();
return response()->render('posts.index', [
'posts' => $posts,
]);
}
public function show($slug)
{
$post = Post::where('slug', $slug)->first();
if (!$post) {
response()->redirect('/404');
return;
}
return response()->render('posts.show', [
'post' => $post,
]);
}
}
```
**Notes:**
- Use `request()`, `response()`, `auth()`, `db()` and other Leaf globals directly - they are available everywhere.
- Do not extend `Leaf\Controller` directly. Extend the base `Controller` that ships with the project.
- Keep controllers thin. Delegate complex logic to service classes or models.
---
## Models (MVC)
Models live in `app/models/`. They extend the base `Model` class which is already set up in `app/models/Model.php`.
```php
body));
$minutes = max(1, (int) ceil($words / 200));
return $minutes . ' min read';
}
}
```
**Notes:**
- Do not extend `Leaf\Model` directly. Extend the base `Model` that ships with the project.
- Models use an Eloquent-like API (see Database section).
- **`$table` is optional.** The model automatically resolves the table name as the snake_case plural of the class name - `Post` → `posts`, `BlogPost` → `blog_posts`. Only define `$table` when your table name doesn't follow this convention (e.g. a legacy table with an irregular name).
---
## Database
### Schema Files (MVC - replaces migrations)
One YAML file per table in `app/database/`. **This is not Laravel-style migrations** - there are no separate migration PHP files. Each table has one YAML file that defines its full schema.
```yaml
# app/database/posts.yml
columns:
title:
type: string
slug:
type: string
unique: true
excerpt:
type: text
nullable: true
body:
type: longText
author:
type: string
default: Anonymous
cover_image:
type: string
nullable: true
role:
type: enum
values: [admin, user, guest]
default: user
verified_at:
type: timestamp
nullable: true
relationships:
- Team # generates team_id FK - do NOT also add team_id manually in columns
seeds:
count: 10
data:
name: '@faker.name'
email: '@faker.unique.safeEmail'
age: '@faker.numberBetween(18, 65)'
password: '@hash("password")'
```
- Seed `@` tokens mirror the PHP call exactly: any Faker formatter/modifier with typed JSON args (`@faker.randomElement(["a", "b"])`), `@tick.subtract(30, "day").format("YYYY-MM-DD")`, `@randomString(32)`, `@hash("secret")`. Optional `locale: fr_FR` under `seeds` for locale-aware data. Non-token strings pass through unchanged.
**Critical schema rules:**
- **Never manually add `id`, `created_at`, or `updated_at`** - they are auto-added to every table.
- Disable them with `increments: false` / `timestamps: false` if genuinely not needed.
- One file per table. File name = table name (e.g. `posts.yml` → `posts` table).
- **Never manually add foreign key columns** (e.g. `user_id`). Use the `relationships` key instead - `- User` automatically generates `user_id`. Adding it manually in `columns` as well will cause a duplicate column error.
```bash
leaf db:migrate # apply all schema files (diffs against last applied state)
leaf db:rollback # undo last version (--step=N for more)
leaf db:seed # run seeders
leaf db:reset # rollback all + re-migrate
```
- Applied-state history lives in a `leaf_schema_history` table in the database itself (per environment, per connection). v4 `storage/database` snapshots are imported automatically on first migrate.
- `db:rollback` changes the database only - schema files are left untouched and become "ahead"; run `db:migrate` to re-apply or edit them to match.
### Query Builder - `db()`
```php
// Basic app: connect first
db()->connect([
'host' => '...',
'dbname' => '...',
'username' => '...',
'password' => '...',
]);
// MVC: connection is automatic from .env - just use db()
db()->select('users')->all();
db()->select('users')->where('id', 1)->first();
db()->select('users')->where('age', '>', 18)->orderBy('name')->limit(10)->all();
db()->insert('users')->params(['name' => 'John'])->unique('email')->execute();
db()->update('users')->params(['name' => 'Jane'])->where('id', 1)->execute();
db()->delete('users')->where('id', 1)->execute();
db()->select('users')->hidden('password')->all();
db()->select('users')->count();
$id = db()->lastInsertId();
// Transactions
db()->transaction(function ($db) {
$db->insert('orders')->params([...])->execute();
$db->update('stock')->params([...])->execute();
});
```
### Models - Eloquent-like API
```php
Post::all();
Post::find(1);
Post::where('status', 'active')->get();
Post::where('slug', $slug)->first();
Post::orderBy('created_at', 'desc')->get();
$post = new Post;
$post->title = 'Hello';
$post->save();
$post = Post::find(1);
$post->title = 'Updated';
$post->save();
$post->delete(); // hard delete; use SoftDeletes trait for soft delete
```
---
## Request
```php
// get() works for ALL input types (GET, POST, JSON, files)
$val = request()->get('field');
$data = request()->get(['name', 'email']);
$data = request()->get('field', false); // disable sanitization
$body = request()->body();
$data = request()->object(); // body as an object: $data->name, $data->meta->tag
// Type-specific
request()->query('name'); // URL params only
request()->postData('name'); // POST body only
request()->files('avatar'); // raw $_FILES data - use only if you need the raw array
// File uploads - always use upload() instead of manually handling $_FILES
$file = request()->upload('image', 'uploads/');
// Returns the saved file path on success, or false on failure
// Leaf handles validation, moving the file, and generating the path
// Upload with options
$file = request()->upload('image', 'uploads/', [
'maxSize' => 2048, // max size in KB
'extensions' => ['jpg', 'jpeg', 'png', 'gif'], // allowed extensions
]);
if (!$file) {
$errors = request()->errors();
}
request()->params('name', 'default'); // with default
// Validation - returns false on failure
$data = request()->validate([
'email' => 'email',
'name' => 'text|min:2',
'bio' => 'optional|string|min:8',
'age' => 'between:[18,100]',
'role' => 'in:[admin,user]',
]);
if (!$data) {
$errors = request()->errors();
}
```
**Validation notes (Form module):**
- Custom rules receive the full validated data set as a 4th argument: `form()->rule('name', function ($value, $param, $field, $data) { ... })`.
- `matchesvalueof:field` compares against another field within the validated data (e.g. `password_confirmation => 'matchesvalueof:password'`).
- Per-field error messages use `'field.rule'` keys: `form()->messages(['email.email' => 'Enter a valid email'])`.
- `form()->submit()` is deprecated - validate, then run your own logic.
---
## Response
```php
response()->json($data);
response()->json($data, 201);
response()->plain('text');
response()->markup('
html
');
response()->exit('error', 500); // respond and stop execution
response()->die('error', 500); // alias for exit
// Redirects
response()->redirect('/path');
response()->redirect(['route-name']); // named route
response()->redirect('https://external.com');
// Views (MVC)
response()->render('posts.index', ['posts' => $posts]); // Blade / BareUI
response()->render('errors.404', [], 404); // render with an HTTP status code
response()->inertia('Posts/Index', ['posts' => $posts]); // Inertia
// Downloads: streamed in chunks (flat memory for any size) and HTTP Range
// requests (pause/resume, parallel segments) are handled automatically
response()->download('storage/exports/report.zip', 'report.zip');
// view() shorthand - usable directly (e.g. in route index.php)
view('404');
view('home', ['name' => 'John']);
// Chaining
response()->withHeader('X-Custom', 'value')->json($data);
response()->withCookie('name', 'val', time() + 86400)->json($data);
response()->withFlash('msg', 'Done!')->redirect('/home');
```
---
## Views - Blade (MVC default)
Views live in `app/views/`. Use dot notation for subdirectories: `'posts.index'` resolves to `app/views/posts/index.blade.php`.
```blade
{{-- Layout --}}
@extends('layouts.app')
@section('title', 'Page Title')
@section('content')
...
@endsection
{{-- Output --}}
{{ $name }} {{-- escaped --}}
{!! $html !!} {{-- unescaped --}}
{{-- Control --}}
@if($condition) ... @endif
@foreach($items as $item) ... @endforeach
{{-- Auth helpers --}}
@auth ... @endauth
@guest ... @endguest
@is('admin') ... @endis
@can('edit') ... @endcan
{{-- Forms --}}
@csrf
{{-- Assets --}}
@vite('app.css')
@alpine
```
---
## Inertia (React/Vue/Svelte frontends)
Render with `response()->inertia('component', $props)` or the `inertia()` helper. Advanced props mirror inertia-laravel v2:
```php
use Leaf\Inertia;
response()->inertia('dashboard', [
'user' => auth()->user(),
'stats' => Inertia::optional(fn () => Stats::heavy()), // only sent when requested via partial reload
'feed' => Inertia::defer(fn () => Feed::load()), // auto-fetched by the client after first render
'teams' => Inertia::defer(fn () => Team::all(), 'group2'), // defer groups load in parallel requests
'posts' => Inertia::merge(fn () => Post::paginate())->matchOn('id'), // client appends instead of overwriting
'errors' => Inertia::always(fn () => flash()->display('errors') ?? []), // survives partial reload filters
]);
Inertia::share('appName', 'My App'); // shared with every page
Inertia::version(fn () => \Leaf\Vite::manifestHash()); // asset version; mismatch => 409 full reload
Inertia::encryptHistory(); // encrypt browser history for sensitive pages
Inertia::clearHistory(); // call on logout
Inertia::location('https://external.com'); // redirect out of the SPA (handles Inertia's 409 protocol)
```
---
## `app/routes/index.php` - Global Setup
This file is autogenerated and loads before all route partials. Leave it as-is for simple apps. Modify it when you need:
- Custom 404 / 500 handlers
- Global middleware (`app()->use(...)`)
- App-wide hooks (`app()->hook(...)`)
- Global template variables shared across all views
- Helper functions available throughout the app
```php
set404(fn () => response()->markup(view('404'), 404));
// Custom 500
app()->setErrorHandler(fn () => response()->markup(view('500'), 500));
// Global middleware
app()->use(SomeMiddleware::class);
// Hooks - share data with all views before routing
app()->hook('router.before.route', function () {
app()->template()->share('appName', _env('APP_NAME'));
});
// Global helper functions can be defined here too
function formatDate($date): string
{
return date('F j, Y', strtotime($date));
}
```
---
## Middleware
```php
// Global - runs on every request (defined in app/routes/index.php)
app()->use(function () {
if (!auth()->user()) response()->exit('Unauthorized', 401);
});
// Named - register once, use on any route
app()->registerMiddleware('auth', function () {
if (!auth()->user()) response()->redirect('/login');
});
app()->get('/dash', ['middleware' => 'auth', 'DashController@index']);
// Built-in auth middleware (no registration needed)
// auth.required - redirects to login if not authenticated
// auth.guest - redirects away if already authenticated
// auth.verified - redirects if email not verified
// is:rolename - checks role
// can:ability - checks permission
app()->get('/dash', ['middleware' => 'auth.required', 'DashController@index']);
app()->get('/login', ['middleware' => 'auth.guest', 'AuthController@index']);
app()->get('/admin', ['middleware' => 'is:admin', 'AdminController@index']);
// Middleware class (generate with: leaf g:middleware LogRequest)
app()->use(LogRequestMiddleware::class);
// Pass data through middleware
app()->registerMiddleware('loadUser', fn ($next) => response()->next(auth()->user()));
// In route handler:
$user = request()->next(); // only call once - consumed on first read
```
---
## Auth
```bash
leaf install auth
leaf scaffold:auth # MVC: generates full auth system
```
Key behavior (answers agents otherwise dig from source):
- The users table needs only an id column, `email`, and your `password.key` column. Roles need NO schema: a `leaf_auth_user_roles` column is auto-created on first `assign()`, even on existing tables.
- `unique` config defaults to `['email', 'username']`; fields missing from the data are skipped safely. Set `['email']` when you have no username column.
- `$user->get()` hides `id` and `password` (config `hidden`) and does NOT include roles. Use `$user->id()`, and `$user->roles()` — for API responses: `[...$user->get(), 'roles' => $user->roles()]`.
- After `assign()`, read `roles()` — the in-memory user data is not refreshed.
- Bearer flow: `auth()->user()` rebuilds the user (with roles) from the JWT each request, so `auth()->user()->is('admin')` works statelessly.
- For JSON APIs, override middleware failures: `auth()->middleware('auth.required', fn () => response()->json(['error' => 'Unauthorized'], 401));` (same for `is`/`can` with 403).
```php
// Login
$ok = auth()->login(['email' => $email, 'password' => $password]);
if ($ok) {
auth()->user();
auth()->data();
} else {
auth()->errors();
}
// Register
$ok = auth()->register(['name' => $n, 'email' => $e, 'password' => $p]);
// Current user
auth()->user(); // object or null
auth()->id(); // ID or null
// Roles & permissions
auth()->createRoles(['admin' => ['edit', 'delete'], 'user' => ['view']]);
auth()->user()->assign('admin');
auth()->user()->is('admin');
auth()->user()->can('edit');
// Config
auth()->config('session', true); // use sessions (default: JWT)
auth()->config('db.table', 'admins');
auth()->config('id.key', 'admin_id');
auth()->config('hidden', ['password']);
// Connect auth to a specific DB connection
auth()->dbConnection(db()->connection('mydb'));
// Override default auth redirects
auth()->middleware('auth.required', fn () => response()->redirect('/login'));
auth()->middleware('auth.guest', fn () => response()->redirect('/dashboard'));
```
---
## Billing (subscriptions — Stripe & Paystack)
Scaffold with `leaf scaffold:subscriptions`, publish tiers with `leaf config:billing`. Cancellations default to period end (user keeps paid-for access — a "grace period").
```php
// checkout + plan management
billing()->subscribe(['id' => $tierId]); // returns Session; redirect to ->url()
billing()->changeSubscription(['id' => $tierId]); // in-place swap w/ proration (Stripe); disable+recreate (Paystack)
billing()->portal('/dashboard'); // hosted manage-billing url (update card, invoices) or null
// user subscription state (grace-period aware)
auth()->user()->hasActiveSubscription(); // active, trialing, or cancelled-but-inside-paid-period
auth()->user()->onTrial();
auth()->user()->onGracePeriod(); // cancelled at period end, access still running
auth()->user()->hasPastDueSubscription(); // renewal failed, in dunning
auth()->user()->cancelSubscription(); // at period end; pass false for immediate
auth()->user()->resumeSubscription(); // undo period-end cancel during grace
// webhooks (stateless — no auth()/session(); Event resolves everything from the db)
$event = billing()->webhook(); // verifies signature, returns Event
$event->id(); // store handled ids for idempotency
$event->renewSubscription(); // on invoice.payment_succeeded (subscription_cycle)
$event->markSubscriptionPastDue(); // on invoice.payment_failed
$event->activateSubscription(); // on subscription create/update
$event->cancelSubscription(); // keeps grace period; pass false to revoke now
```
---
## Dates (leafs/date — dayjs-style)
```php
tick(); // now
tick('2026-01-15 12:00:00'); // parse a date string
tick($str, 'Asia/Tokyo'); // parse as wall-clock time IN that zone (dayjs semantics)
tick($utc, 'UTC')->tz('America/New_York'); // tz() CONVERTS an existing instant to another zone
tick()->utc(); // convert to UTC (store dates in UTC)
tick()->utcOffset(); // minutes from UTC
tick()->format('YYYY-MM-DD HH:mm:ss'); // dayjs tokens, NOT php date() tokens
tick()->add(1, 'month')->startOf('day');
tick($date)->fromNow(); // "2 hours ago"
```
Calendar flow: parse in the user's zone → `->utc()` to store → `->tz(viewerZone)` to render.
---
## Redis (leafs/redis)
```php
redis()->set('key', 'value', 60); // optional ttl in seconds
redis()->get('key'); // false if missing
redis()->increment('hits'); // atomic counters (also decrement)
redis()->expire('key', 3600); redis()->ttl('key');
redis()->hSet('user:1', 'name', 'x'); // any other redis command passes through to phpredis/predis
```
Uses phpredis when the extension is loaded, falls back to predis/predis automatically.
---
## Errors & Crash Reports (leafs/exception v5 — "Leaf Crash")
Debug mode shows a crash screen with stack + code excerpts + user journey; production shows a clean page while the report still reaches logs/reporters. The journey is recorded automatically: router requests, ALL db/model queries, log lines, cache misses, fetch() HTTP calls, view renders.
```php
// add your own journey steps (about 1us each, always-on safe)
crash()->leaveCrumb('coupon applied', 'action', ['total_after' => $total]);
// file a report WITHOUT an exception — for flows that "work" but return wrong data
crash()->capture('total is 0 but cart has items', [
'level' => 'warning',
'peeks' => ['order' => $order], // bounded, redacted variable snapshots
]);
crash()->span('db: heavy report', fn () => $query->run()); // perf spans, ride on reports
crash()->reportTo($reporter); // Reporter interface: log file, webhook, Alchemy Cloud
```
Secrets (password/token/auth/cookie/card keys) are stripped when reports are built — never rely on hiding them at display time. Query crumbs record prepared SQL only, never bindings.
---
## Sessions & Security
```php
session()->set('key', 'value');
session()->get('key', 'default');
session()->get('key', null, false); // third arg false = skip HTML-escaping on read (values are stored raw)
session()->set('user.prefs.theme', 'dark'); // dot notation nests to any depth (get/has/delete too)
session()->retrieve('key'); // get and delete (flash value)
session()->delete('key');
session()->clear();
// CSRF
app()->csrf(); // Basic app
// MVC: run `leaf install csrf` - auto-enabled after that
// In Blade forms: @csrf
// In JS requests: X-CSRF-Token header
// 'rotate' => true config makes tokens single-use (opt-in); csrf()->regenerate() issues a fresh token manually
// Secret: derived from APP_KEY automatically. Override with X_CSRF_SECRET in .env or 'secret' config (code wins).
// No APP_KEY and no secret set = startup error. Fix: `php leaf key:generate`
// SPAs: Leaf sets an XSRF-TOKEN cookie automatically and accepts the X-XSRF-TOKEN header; disable with 'cookie' => false
// XSS - Anchor sanitizes all Leaf input automatically
// Manual sanitization:
anchor()->sanitize($rawData);
```
---
## Installing Modules
```bash
leaf install auth
leaf install db
leaf install mail
leaf install cors
leaf install session
leaf install cache
leaf install fs@v4
leaf install fetch
leaf install lingo # i18n / translations
leaf install sitemap
leaf install queue # MVC only
leaf install stripe # MVC only
leaf install paystack # MVC only
```
**Everything wires up automatically. Never manually configure what `leaf install` handles.**
---
## Generator Commands
```bash
leaf g:controller Posts # controller
leaf g:controller Posts -m # controller + model
leaf g:controller Posts -a # controller + model + schema
leaf g:controller posts --resource # full CRUD controller
leaf g:model Post # model only
leaf g:schema posts # schema YAML only
leaf g:middleware LogRequest # middleware class
leaf g:mailer Welcome # mailer class
leaf g:job SendEmail # queue job
leaf g:template home # view file
leaf scaffold:auth # full auth system
leaf scaffold:subscriptions # billing/subscriptions
leaf scaffold:landing-page # product homepage
leaf scaffold:waitlist # waitlist + access middleware
leaf scaffold:blog # markdown blog
leaf scaffold:contact # contact form wired to leaf mail
leaf scaffold:legal # privacy policy + terms pages
leaf scaffold:ai # streaming Claude chat (needs ANTHROPIC_API_KEY)
leaf scaffold:mail # install leaf mail + config
leaf scaffold:shadcn # shadcn/ui for react apps
```
Feature scaffolds ship blade/react/vue/svelte variants - auto-detected from the app's inertia setup, or forced with `--scaffold react|vue|svelte|default`.
---
## Module Notes
- Deploy: `php leaf deploy` deploys to Fly.io in one command (scaffold + launch/deploy, secrets hint after); `php leaf deploy --to render` prepares Dockerfile + render.yaml for git-based Render deploys. Flags: --name, --region. Works for both MVC (public/ docroot) and single-file lite apps (app-root docroot, with vendor/composer/.env denied). Unknown providers exit 1.
- **CORS**: allowed origins must be full origins (scheme included) and match exactly, or be a regex string (e.g. `'/^https:\/\/.*\.myapp\.com$/'`).
- **Sitemap**: set `Sitemap::$maxAge` (seconds) to control how long a generated sitemap is cached before it is rebuilt.
- **Lingo**: plug in a custom locale-detection strategy (a `Lingo\Handler` subclass) via the `locales.customStrategy` config.
- **Mail**: `cc` and `bcc` accept arrays of addresses.
- **Cache**: `cache()` = store, `cache('key')` = get, `cache('key', ttl, value)` = remember (existing value wins), `cache('key', value)` = store forever. Only closures are lazily evaluated - plain strings are cached literally even if they match a function name.
- **S3**: uploads accept a `'visibility'` option ('private' stays private; default 'public'); bucket connections are configured with the `'endpoint'` key.
- **Cookie**: call `Cookie::setDefaults(['path' => '/'])` once - deletion uses the configured path/domain, so defaults keep set and unset consistent. `simpleCookie()` accepts a strtotime string ('7 days') or timestamp expiry.
---
## Environment Variables
```env
APP_NAME="My App"
APP_ENV=production
APP_KEY=secret
APP_URL=https://myapp.com
APP_DOWN=false
DB_HOST=127.0.0.1
DB_NAME=myapp
DB_USER=root
DB_PASSWORD=secret
AUTH_SESSION=true
AUTH_DB_TABLE=users
AUTH_TOKEN_SECRET=secret
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=xxx
MAIL_PASSWORD=xxx
CORS_ALLOWED_ORIGINS='*'
QUEUE_CONNECTION=database
BILLING_PROVIDER=stripe
```
Access anywhere: `_env('KEY', 'default')`
---
## Testing & Code Quality (Alchemy)
Alchemy replaces phpunit.xml, php-cs-fixer, rector, phpstan configs and hand-written CI with a single `alchemy.yml`. Install with `leaf install alchemy --dev` (or composer), then `./vendor/bin/alchemy init`. Tools install lazily — pest arrives on the first `composer run test`, phpstan on the first `composer run analyse`, never before.
Commands (wired into composer scripts by init): `composer run test | lint | fmt | refactor | analyse | ci`, and `composer run alchemy` (= `alchemy all`) runs everything present in alchemy.yml. `lint` and `refactor -- --check` only report; `fmt` and `refactor` rewrite. `alchemy eject` exports real config files (no lock-in). `alchemy switch gitlab|circleci|github|pest|phpunit` swaps CI provider or test engine.
Full config surface:
```yaml
app: # your code dirs — shared by coverage, lint, refactor, analyse
- src
tests:
engine: pest # or phpunit; parallel uses pest --parallel or paratest
parallel: true
flags: [tia] # standing engine flags, any pest/phpunit option (pest 5: tia, shard=1/4, ...)
paths: [tests]
files: ['*.test.php']
suites: # named suites with per-suite paths/files/exclude
Unit: { paths: [tests/unit] }
config: { stopOnFailure: true } # any phpunit.xml attribute, verbatim
env: { APP_ENV: testing }
ini: { memory_limit: 512M }
coverage: { exclude: [src/legacy] }
lint:
provider: phpcsfixer # or pint (auto-selected with preset laravel on Laravel projects; pint reads the same rules,
# pint-only keys like notPath/notName pass through verbatim, --flags forwards runtime flags e.g. `composer run fmt -- --flags=dirty`)
preset: PSR12
risky: false
rules: { single_quote: true } # any php-cs-fixer rule
autofix: true # CI commits style fixes instead of failing (GitHub only)
analyse:
level: 6 # phpstan 0-10
baseline: phpstan-baseline.neon # root baseline auto-included anyway
ignore: ['#pattern#']
# ANY other key passes through to phpstan verbatim (includes, excludePaths, ...)
# pest projects: when analyse paths cover the tests, alchemy auto-installs and wires
# pestphp/pest-plugin-phpstan (pest 5 / PHP 8.4) so pest syntax analyses cleanly
# laravel projects: larastan/larastan is auto-installed and wired in, so facades/Eloquent analyse cleanly
refactor: # rector — only runs when this section exists (it rewrites code)
php: '8.2' # upgrade sets (true = read composer.json)
sets: [dead-code, code-quality, type-declarations] # all 20 rector prepared sets, kebab-cased
skip: [src/legacy]
import-names: true
fluent-new-line: true
actions: # CI generation via `composer run ci`
provider: github # or gitlab, circleci, or a list
run: [lint, tests, analyse, refactor]
os: [ubuntu-latest]
php: { extensions: 'json, zip', versions: ['8.3'] }
events: [push, pull_request]
```
Key rules:
- **A section's value can be a filename instead of a map** — `tests: phpunit.xml` or `analyse: phpstan.dist.neon` pins that tool to the user's own config file, run as-is. Map = alchemy-managed (config generated fresh per run inside `.alchemy/`, discarded after the run — only engine caches persist; project root never written).
- `alchemy init` writes the full pipeline (tests, lint, analyse, refactor, actions) — a section's presence opts the tool in, deleting a section opts out. `actions.run` defaults to [lint, tests]; add analyse/refactor there to run them in CI too. It asks port-or-keep for each existing tool config it finds (`--port`/`--keep` to answer non-interactively); it can port phpunit.xml, php-cs-fixer configs, pint.json, phpstan neon files and rector.php into alchemy.yml. On Laravel projects init selects pint + preset laravel for lint.
- A tool with no section but a matching config file in the project still runs on that file (safety net).
- Generated CI files carry a `# Generated by Leaf Alchemy` header and regenerate on every run; remove the header to take ownership of a file.
- Do NOT commit or hand-edit anything inside `.alchemy/` — it holds per-run generated configs (deleted after each run) and engine caches, and is gitignored.
---
## Common Mistakes to Avoid
1. **Don't use `app/controllers/` in a Basic app.** Routes live in `index.php`.
2. **Don't use `leaf.config.php` or a `config/` folder.** Config goes in `.env`.
3. **Don't call `app()->run()` in MVC.** It's handled by the framework.
4. **Don't create PHP migration files.** Use YAML schema files in `app/database/`.
5. **Don't add `id`, `created_at`, or `updated_at` to schema files.** Auto-added.
6. **Don't extend `Leaf\Controller` or `Leaf\Model` directly.** Extend the base `Controller` and `Model` classes that ship with the project.
7. **Don't edit `public/index.php` or `app/routes/index.php`** unless you have a specific reason (global middleware, custom error pages, etc.).
8. **Don't manually copy `.env.example` to `.env`.** `leaf create` does this automatically.
9. **Don't use square brackets on routes unless you need route parameters** (middleware, name, module keys). Plain routes: `app()->get('/path', 'Controller@method');`
10. **Don't hardcode credentials.** Use `_env('KEY', 'default')`.
11. **Don't install third-party packages** when a Leaf module exists.
12. **Don't mix `db()` and Models in the same transaction** - they must share a connection.
13. **`request()->next()` is consumed on first read** - only call it once per request.
14. **Inertia redirects use 303**, not 302, to prevent method re-use.
15. **Don't define `$table` in a model unless the table name is irregular.** Leaf resolves it automatically from the class name (`Pin` → `pins`, `BlogPost` → `blog_posts`).
16. **Don't add foreign key columns manually in schema files.** Use `relationships: - ModelName` - it generates the FK column automatically. Adding it in both `columns` and `relationships` causes a duplicate column error.
17. **Don't use `move_uploaded_file()` or manually handle `$_FILES` for uploads.** Use `request()->upload('field', 'destination/')` - Leaf handles everything including validation and file moving.
---
## AI-native shared context
Leaf projects use `.leaf/CONTEXT.md` as shared working memory. Agents running inside a project should read it alongside the filesystem, then update useful project knowledge when their work is complete. Leaf MVC and projects created through Leaf CLI require no extra AI setup.
The file follows the **leaf.context v1** format (marker `` on line 1) so edits from different agents compose. Rules: sections are `##` headings in their existing order (preserve unknown sections); underscore-wrapped lines containing `agent:` are instructions to you — act, then replace them; Recent Changes entries are `* YYYY-MM-DD — what changed`, newest first, five max; Known Decisions carry their reasoning; one Current Goal at a time; never store secrets; never duplicate mechanical info (routes/models/modules) that lives in code and `leaf context`.
When an agent cannot access the project, the user can run:
```bash
leaf context # prints a compact context handoff (beta: report issues at github.com/leafsphp/cli/issues)
```
The pasted output tells you:
- Which entry point they're using
- Installed modules
- Route definitions
- App structure
Use it to generate accurate, project-specific code instead of generic boilerplate. The command output is generated by scanning the project (mechanical map: routes with handlers/middleware, modules, models, schema files, env key names) with the shared memory appended — the opposite half of the two-way `.leaf/CONTEXT.md`, which holds only goals and decisions.