- At Leaf, we're focused on making PHP development as simple and elegant as possible.
- Our team is constantly improving the framework and its ecosystemβtune in as we share what's new and what's
- coming next.
-
+ Get notes on Leaf 5, AI-native PHP, and product-building in your inbox. You can also read our blog posts at
+ blog.leafphp.dev.
+ You may also go social at
+ Twitter,
+ join our
+ discussions,
+ or watch our
+ videos on YouTube.
+ Structure for real apps, without the framework weight.
+
+
+ Leaf MVC gives your project controllers, models, routes, views, services, and environment conventions from day
+ one, while keeping the codebase small enough for humans and AI assistants to understand.
+
+ A route receives the request, a controller decides what should happen, models and services handle the work,
+ and a view or JSON response returns the result. That predictable flow is why Leaf MVC works well for teams and
+ AI-assisted changes.
+
+ Leaf gives you the clean starting point first: routing, structure, helpers, and a project shape that stays readable for you, your team, and your AI tools.
+
+
+
+
+
+
+
+
+
+ terminal
+
+
+
$ composer global require leafs/cli -W
+
$ leaf create my-app
+
$ cd my-app
+
$ leaf serve
+
+
+
+
+ Open http://localhost:5500 and you have a running app. No setup maze, no ceremony.
+
+
+
+
+
Why it matters
+
+
+
Small surface area
+
Start with the parts you need. Add auth, database, queues, mail, and more when the app asks for them.
+
+
+
AI-readable by default
+
Routes, modules, and app conventions stay obvious, so assistants can generate code that fits your project.
+
+
+
Still just PHP
+
No hidden runtime or lock-in. Deploy anywhere PHP runs.
+
+
+
+
+
+
+
Everything you need, nothing you don't
+
+
Leaf is designed to stay light while giving you production-ready pieces as soon as you need them.
+ Give your agent the context your PHP app deserves.
+
+
+ Leaf makes your routes, modules, structure, conventions, and project state readable to AI, so assistants can
+ build inside your app instead of guessing around it.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ layer.label }}
+ {{ layer.eyebrow }}
+
+
+
{{ layer.title }}
+
{{ layer.description }}
+
+
+
+
+
+
AI composer
+
+ Ask your agent
+
+
+
+
+
+
+ Build a customer portal with billing and a CMS view
+
+
+
+
+ Shared context connected
+ two-way sync
+
+
+
+
+ reads shared project memory
+ finds routes and modules
+ uses app conventions
+ writes project knowledge back
+
+ Build alongside developers shaping how modern products are made. Share what you're building, get help when
+ you're stuck, and compare the ways people are building with AI in real Leaf apps.
+
+
-
-
Dig into our vibrant
- community with us.
+
+
+
+
+
-
- Our first community meet up was in 2023 which we held together with SeevCash. Since then our community has
- gotten bigger, growing together with Leaf and all the tools in the ecosystem. Join our young but vibrant
- community and the Leaf team as we discuss our insights from the past year and whatβs to come in the next
- couple of years π
-
+
+
+ The Leaf Discord is where releases drop first, questions get answered, and builders show off what they're
+ shipping, from weekend projects to production apps.
+
Leaf is built for makers who move fast. Whether you are building a web app, API, microservice or console application, Leaf
gets you up and running in seconds. With no config and effortless deployment, you can run Leaf anywhere PHP is
- availableβinstantly!
+ available, instantly!
- If you're tired of frameworks that dictate your frontend choices, Leaf is different. Use any frontend framework
- you prefer β Vue, React, Svelte, Blade or bring your own view engine.
+
+
+
Frontend
+
Your stack stays yours
+
+ Use any frontend you want. React, Vue, Svelte, Blade, PHP. Your AI assistant still gets the same backend map.
+ Your tools follow your stack.
render('partials/footer', [
+ echo $template::render('partials/footer', [
'year' => date('Y'),
]);
?>
diff --git a/src/docs/frontend/blade.md b/src/docs/frontend/blade.md
index 68c22f4c..b16566a4 100644
--- a/src/docs/frontend/blade.md
+++ b/src/docs/frontend/blade.md
@@ -2,29 +2,23 @@
-
-
-Blade is Laravel's own templating engine that makes creating dynamic views easy. It lets you mix regular PHP code with its own features for more flexibility, has a clean syntax and caches your views for faster performance.
-
-Leaf Blade is an adaptation of the original Blade package that allows you to use Blade templates in your Leaf PHP projects powered by [jenssegers/blade](https://github.com/jenssegers/blade).
-
-
-
-::: details New to Blade?
-
-This video by The Net Ninja will help you get started with blade.
-
-
+
+
+
+
Blade views
+
Use Blade when your product wants expressive server-rendered UI.
+
Leaf Blade brings familiar Blade templates to Leaf apps, with cached views, layouts, sections, and syntax that AI assistants already understand well.
+
+
+
+
$ leaf install blade@v4
+
<h1>Hello, {{ $name }}</h1>
+
+
+
+
-:::
+Blade is Laravel's templating engine for creating dynamic views. Leaf Blade is an adaptation that allows you to use Blade templates in Leaf PHP projects, with extra directives built for the Leaf ecosystem.
## Setting Up
@@ -88,7 +82,9 @@ Blade views are a pretty sweet mixture of HTML, PHP, and clean syntax. You can c
:::
-This should look pretty familiar if you know HTML (of course you do). The only difference is the `{{ $name }}` part. This is Blade's way of creating a variable in your view. When you render this view, Blade will allow you pass in a variable called `$name` and it will be displayed in place of `{{ $name }}`. Let's see how you can render this view.
+This should look pretty familiar if you know HTML (of course you do). The only difference is the `{{ $name }}` part. This is Blade's way of creating a variable in your view.
+
+When you render this view, Blade will allow you pass in a variable called `$name` and it will be displayed in place of `{{ $name }}`. Let's see how you can render this view.
-
-Both Leaf and Leaf MVC offer first-class support for frontend tooling and libraries. This includes support for different templating engines, CSS preprocessors, and JavaScript libraries.
+
+
+
+
Frontend freedom
+
Use the frontend that fits your product, not the one your backend forces on you.
+
Leaf works with simple PHP views, Blade, BareUI, Vite, Tailwind, Inertia, React, Vue, Svelte, and third-party engines. Your backend stays readable while your UI stack stays yours.
+
+
+
+
+
+
+
+ views
+
+
+
leaf view:install
+
blade, bareui, inertia, vite, tailwind
+
+
+
+
+
Choose by workflow
+
+
+
Templates
+
Blade or BareUI when server-rendered pages are enough.
+
+
+
Modern assets
+
Vite and Tailwind when you want fast local builds and utility styling.
+
+
+
App UIs
+
Inertia when React, Vue, or Svelte should talk to Leaf directly.
+
+
+
+
+
## Templating Engines
-Leaf is modular and allows you to use any templating engine you want, however, it comes with 2 first-class templating engines:
-
-- Leaf's BareUI engine
-- Laravel's Blade engine
-
-While both of these engines are great, they both have their own strengths and weaknesses. Leaf's BareUI engine is a simple, lightweight, and fast engine but it's not as feature-rich as Blade. Blade, on the other hand, is a feature-rich engine with a lot of features but it's not as fast as BareUI since it has to compile and cache views.
-
-BareUI relies on PHP's innate templating capabilities so it's syntax is PHP's syntax. Blade, on the other hand, has its own syntax using `@` directives. They are both great engines and the choice of which to use is up to you.
-
-| Engine | Speed | Cool Magic | Lightweight | Editor Support |
-| -------------------------------- | :-----: | :----------: | :-----------: | :------------: |
-| [bareui](/docs/frontend/bareui) | β‘οΈ | β | β‘οΈ | β‘οΈ |
-| [blade](/docs/frontend/blade) | β | β‘οΈ | β | β‘οΈ |
+Leaf is modular and lets you use any templating engine you want. It includes first-class support for two common options:
+
+- Leaf's [BareUI](/docs/frontend/bareui) engine
+- Laravel's [Blade](/docs/frontend/blade) engine
+
+BareUI relies on PHP's native templating capabilities, so its syntax is just PHP. Blade has its own directive syntax and a larger feature set. Both are valid choices; pick based on how much template power you want.
+
+
+ Blade
+ Feature-rich templates
+ More compilation overhead
+
+
## Asset Bundling
-Leaf provides first-class support for asset bundling using [Vite](https://vite.dev/). Vite is a modern build tool for frontend applications which aims to provide a faster and leaner development experience for modern web projects. Vite and Leaf make the perfect pair for building modern web applications since they are both fast and lightweight.
-
-
+Leaf provides first-class support for asset bundling using [Vite](https://vite.dev/). Vite gives modern frontend projects fast development, ES modules, JSX, TypeScript support, and production builds.
-Bundling assets allows you to write your frontend code in a modular way and then bundle it into a single file for production. This makes your frontend code more maintainable and easier to work with. Vite also allows you to use modern JavaScript features like ES6 modules, JSX, and even TypeScript.
-
-The Vite + Leaf stack unlocks a lot of possibilities for building modern web applications with Leaf and your favorite frontend tooling. You can find the full documentation on the [Vite module page](/docs/frontend/vite)
+The Vite + Leaf stack works well when you want Leaf to own the backend while your frontend code stays modular and easy to build. You can find the full documentation on the [Vite module page](/docs/frontend/vite).
## Frontend Frameworks
-Modern web apps are built on the backs of powerful UI libraries like React, Vue, and Svelte. Leaf provides an easy way to integrate these libraries into your Leaf applications using [Inertia.js](https://inertiajs.com/).
-
-
-
-Inertia acts as a bridge between your Leaf backend and your frontend UI library that allows them to communicate seamlessly. This allows you to build modern web applications with Leaf and your favorite frontend library without much of the complexity that comes with modern SPAs.
-
-You can find the full documentation on the [Inertia module page](/docs/frontend/inertia)
+Modern app interfaces often use React, Vue, or Svelte. Leaf integrates with these through [Inertia.js](https://inertiajs.com/), giving your frontend direct access to Leaf-powered pages without building a separate API for every screen.
+
+
+
+## AI context
+
+Frontend choices are part of Leaf's shared project context. Agents inside the project read `.leaf/CONTEXT.md` alongside the filesystem, then keep that map aligned when Blade, BareUI, Vite, Tailwind, or Inertia changes. Run `leaf context` only to print a compact handoff for an external assistant without project access.
diff --git a/src/docs/frontend/inertia.md b/src/docs/frontend/inertia.md
index d535ac6c..3822b5e6 100644
--- a/src/docs/frontend/inertia.md
+++ b/src/docs/frontend/inertia.md
@@ -63,7 +63,7 @@ app()->inertia('/route', 'view', [
::: details Automatic Props
-By default, Leaf automatically passes in some useful props into your inertia views. These include:
+By default, Leaf automatically shares some useful props with every inertia page. These include:
- `auth`: The current auth context, including:
- `user`: The currently authenticated user, or `null` if not authenticated.
@@ -81,6 +81,8 @@ By default, Leaf automatically passes in some useful props into your inertia vie
- `periods`: The available billing periods.
- `_token`: The current CSRF token if CSRF protection is enabled.
+The `auth` prop is shared with every single Inertia page, and `auth.user` contains every column on your user that is not in your auth `hidden` config. Since this data ships to the browser, remember to add any custom sensitive columns to `hidden` in your auth config.
+
:::
## Accessing data passed into views
@@ -154,13 +156,185 @@ use Leaf\Inertia;
Inertia::share('appName', 'Some constant value');
Inertia::share('someDeferredValue', fn() => asyncData()->get() ?? null);
-Inertia::share('specialFlashMessage', function () {
- return flash()->display('specialFlashMessage') ?? null;
-});
+Inertia::share('specialFlashMessage', fn () => flash()->display('specialFlashMessage') ?? null);
```
Using a function to share data is useful when you want to share dynamic data, because the function won't be executed until the data is actually needed, so if you share something like a flash message which can only be read once, it won't be lost.
+One thing to note: Leaf reserves the `auth` prop for its automatic auth data (`id`, `user`, `roles`, `permissions`, `errors`). If you share your own `auth` value with `Inertia::share()`, the framework's value wins, so pick a different key for your own data.
+
+## Optional Props
+
+Some props are expensive to compute and not needed on every visit. You can wrap them in `Inertia::optional()` so they are skipped entirely on the first page load, and only evaluated when your frontend explicitly asks for them in a [partial reload](https://inertiajs.com/partial-reloads):
+
+```php
+use Leaf\Inertia;
+
+response()->inertia('users/index', [
+ 'users' => User::all(),
+ 'stats' => Inertia::optional(fn () => Stats::expensiveCalculation()),
+]);
+```
+
+On the client, you request an optional prop by name:
+
+```js
+router.reload({ only: ['stats'] });
+```
+
+Partial reloads also work the other way. Your frontend can pass `except` instead of `only` to refresh everything but a couple of props, and Leaf will handle both automatically.
+
+::: details Migrating from Inertia::lazy()
+`Inertia::lazy()` still works, but it's deprecated in favour of `Inertia::optional()`, which is the name the official Inertia adapters settled on.
+:::
+
+## Deferred Props
+
+Deferred props take optional props one step further: instead of waiting for you to manually reload, Inertia fetches them automatically right after the page first renders. Your page shows up instantly, and the heavy data streams in behind it:
+
+```php
+use Leaf\Inertia;
+
+response()->inertia('dashboard', [
+ 'user' => auth()->user(),
+ 'stats' => Inertia::defer(fn () => Stats::expensiveCalculation()),
+]);
+```
+
+On the frontend, the `Deferred` component lets you show a placeholder while the data loads:
+
+::: code-group
+
+```jsx [React]
+import { Deferred } from '@inertiajs/react';
+
+Loading...}>
+
+
+```
+
+```vue [Vue]
+
+
+
+
+ Loading...
+
+
+
+```
+
+```svelte [Svelte]
+
+
+
+ {#snippet fallback()}
+ Loading...
+ {/snippet}
+
+
+```
+
+:::
+
+If you have multiple deferred props, they are all fetched together in one follow-up request. You can split them into separate parallel requests by giving them groups:
+
+```php
+response()->inertia('dashboard', [
+ 'stats' => Inertia::defer(fn () => Stats::expensiveCalculation()),
+ 'teams' => Inertia::defer(fn () => Team::all(), 'secondary'),
+ 'projects' => Inertia::defer(fn () => Project::all(), 'secondary'),
+]);
+```
+
+Here `stats` loads in one request while `teams` and `projects` load together in another.
+
+## Merging Props
+
+By default, a new page visit overwrites props completely. For things like infinite scroll or "load more" buttons, you want new data appended to what's already on the client instead. Wrap the prop in `Inertia::merge()`:
+
+```php
+use Leaf\Inertia;
+
+response()->inertia('posts/index', [
+ 'posts' => Inertia::merge(fn () => Post::paginate(request()->get('page'))),
+]);
+```
+
+Now every reload appends the new posts to the existing list on the client. For nested structures you can use `Inertia::deepMerge()`, and if you're merging arrays of objects, `matchOn()` tells Inertia how to recognise existing items so they're updated in place instead of duplicated:
+
+```php
+response()->inertia('users/index', [
+ 'users' => Inertia::merge(fn () => User::paginate())->matchOn('id'),
+]);
+```
+
+When you need to start over, for example after applying a new filter, reset the prop from the client and it will be replaced instead of merged:
+
+```js
+router.reload({ reset: ['users'] });
+```
+
+## Always Props
+
+Partial reloads only send the props your frontend asks for, but some props should be in every response no matter what, like validation errors or a permission check. Wrap them in `Inertia::always()`:
+
+```php
+use Leaf\Inertia;
+
+Inertia::share('errors', Inertia::always(fn () => flash()->display('errors') ?? []));
+```
+
+An always prop survives both `only` and `except` filters, so your frontend can rely on it being present in every response.
+
+## History Encryption
+
+Inertia stores page data in the browser's history state, which means sensitive data can be read back with the back button even after logging out. You can tell Inertia to encrypt the history entry for sensitive pages:
+
+```php
+use Leaf\Inertia;
+
+Inertia::encryptHistory();
+
+response()->inertia('billing/settings', [...]);
+```
+
+When a user logs out, clear the history so earlier pages can no longer be decrypted:
+
+```php
+Inertia::clearHistory();
+
+response()->redirect('/login', 303);
+```
+
+## Asset Versioning
+
+Inertia uses an asset version to know when your compiled assets have changed, so it can force a full page reload instead of serving a stale page. Leaf calculates one for you automatically from your root view, but you can set your own, for example from your Vite manifest:
+
+```php
+use Leaf\Inertia;
+
+Inertia::version(fn () => \Leaf\Vite::manifestHash());
+```
+
+When the client's version no longer matches, Leaf responds with a `409 Conflict` that tells Inertia to do a fresh full-page visit.
+
+## External Redirects
+
+Redirecting an Inertia request to an external site (or any non-Inertia page) needs a special response, since Inertia normally expects JSON back. `Inertia::location()` handles both cases for you:
+
+```php
+use Leaf\Inertia;
+
+Inertia::location('https://checkout.stripe.com/session/...');
+```
+
+Inertia requests get a `409` with an `X-Inertia-Location` header, which makes the client do a full browser visit; regular requests get a normal redirect.
+
## Generating Inertia Views
Once you set up your preferred frontend framework using the `view:install` command, Leaf MVC automatically reconfigures the framework to work primarily with your tooling. So you can generate a new inertia view using the `g:template` command.
@@ -345,7 +519,7 @@ class AccountController extends Controller
{
$user = auth()->user();
- response()->inertia('profile/update', [
+ return response()->inertia('profile/update', [
'errors' => flash()->display('errors') ?? [],
'name' => $user->name ?? null,
'email' => $user->email ?? null,
@@ -372,7 +546,7 @@ class AccountController extends Controller
->redirect('/show-name-change-form', 303);
}
- response()->redirect('/dashboard', 303);
+ return response()->redirect('/dashboard', 303);
}
}
```
@@ -396,4 +570,4 @@ While deployment is pretty much the same as deploying a regular Leaf app, you'll
## Conclusion
-Inertia is the perfect replacement for Blade views in Leaf MVC, and actually allows you build more powerful applications with the tons of available JavaScript libraries out there. It's a great way to build full-stack apps, supercharged by Leaf π
+Inertia is a great replacement for Blade views in Leaf MVC, and it opens up the whole JavaScript ecosystem to your app. It's a lovely way to build full-stack apps with Leaf π§‘
diff --git a/src/docs/frontend/third-party.md b/src/docs/frontend/third-party.md
index 8b9b668b..ee32c3d7 100644
--- a/src/docs/frontend/third-party.md
+++ b/src/docs/frontend/third-party.md
@@ -99,7 +99,7 @@ return [
}
$engine->display($view);
- }),
+ },
/*
|--------------------------------------------------------------------------
diff --git a/src/docs/frontend/vite.md b/src/docs/frontend/vite.md
index f20fb74d..39e7e239 100644
--- a/src/docs/frontend/vite.md
+++ b/src/docs/frontend/vite.md
@@ -4,7 +4,7 @@
Vite is a modern build tool for frontend applications. It aims to provide a faster and leaner development experience for modern web projects.
-Leaf provides a Vite integration which you can use to seamlessly bundle your CSS and JS assets. This allows you to have more complex frontend setups without the need for extra configuration.
+Leaf provides a Vite integration which you can use to bundle your CSS and JS assets. This allows you to have more complex frontend setups without the need for extra configuration.
::: details New to bundling?
@@ -26,6 +26,17 @@ leaf view:install --vite
This command will install vite, and the leaf-vite module which will be used to load your assets on the server side plus all vite-specific dependencies and config files.
+### Vite in lite apps
+
+You don't need Leaf MVC to use Vite, or even a full frontend setup. Commands like `leaf view:install --react` work in lite apps too, and will write your views to a `views/` folder in your project root. For everything to line up, your app needs to know where your views and cache live:
+
+```php
+app()->config('views.path', 'views');
+app()->config('views.cache', __DIR__ . '/storage/cache');
+```
+
+Leaf CLI 5.0.6+ writes this configuration for you automatically when you run `view:install` in a lite app, and leafs/vite 5.0.1+ defaults match this layout out of the box: the hot file and `build/` folder sit in the project root, and built assets are served from `/build`. The full lite app contract lives at [leafphp.dev/ai/references/lite.md](https://leafphp.dev/ai/references/lite.md) if you want the details.
+
## Loading your assets
Once you've installed Vite, you can start loading your assets using the the `vite()` helper function. This function takes in 2 parameters:
diff --git a/src/docs/http/caching.md b/src/docs/http/caching.md
index aa5b8f2a..9be88145 100644
--- a/src/docs/http/caching.md
+++ b/src/docs/http/caching.md
@@ -2,32 +2,30 @@
-
-
-HTTP caching is a way to store copies of web resources (like images, CSS files, or API responses) so they can be quickly accessed later without re-downloading them from the server every time. This speeds up loading times of your application and reduces the load on servers.
-
-Leaf provides a clean interface for caching resources and instructing the client on how to cache them.
-
-::: details New to HTTP Caching?
-
-This video by @roadmapsh will help you understand everything you need to know about HTTP Caching and Cache-Control headers.
-
-
-
-:::
-
-This documentation is a bit more technical and assumes you have a basic understanding of HTTP caching. If you're new to caching, you can watch the video above or [read this article](https://www.keycdn.com/blog/http-cache-headers) to get a better understanding.
+
+
+
+
HTTP performance
+
Tell browsers when a response can be reused.
+
Leaf gives you a small interface for ETags, expiry, last-modified checks, and cache headers so repeated requests can skip work safely.
+
+
+
+
use Leaf\Http\Cache;
+
Cache::etag('menu-v1');
+
Cache::expires('+1 week');
+
+
+
+
+
+HTTP caching stores copies of web resources, like images, CSS files, or API responses, so they can be quickly accessed later without re-downloading them from the server every time. Leaf provides a clean interface for caching resources and instructing the client on how to cache them.
## etag
-An ETag is a unique identifier for a resource URI. After setting the Etag headers, the HTTP client will send an `If-None-Match` header with each subsequent HTTP request of the same resource URI. If the ETag value for the resource URI matches the `If-None-Match` HTTP request header, GET and HEAD requests will return a `304 Not Modified` HTTP response while all others return a `421 Precondition Failed` that will prompt the HTTP client to continue using its cache; this also prevents Leaf from serving the entire markup for the resource URI, saving bandwidth and response time.
+An ETag is a unique identifier for a resource URI. After setting the Etag headers, the HTTP client will send an `If-None-Match` header with each subsequent HTTP request of the same resource URI.
+
+If the ETag value for the resource URI matches the `If-None-Match` HTTP request header, GET and HEAD requests will return a `304 Not Modified` HTTP response while all others return a `421 Precondition Failed` that will prompt the HTTP client to continue using its cache; this also prevents Leaf from serving the entire markup for the resource URI, saving bandwidth and response time.
Setting an ETag with Leaf is very simple. Invoke Leafβs etag method in your route callback, passing it a unique ID as the first and only argument.
@@ -62,7 +60,9 @@ app()->get('/', function () {
## lastModified
-A Leaf provides built-in support for HTTP caching using the resourceβs last modified date. When you specify a last modified date, Leaf tells the HTTP client the date and time the current resource was last modified. The HTTP client will then send a If-Modified-Since header with each subsequent HTTP request for the given resource URI. If the last modification date you specify matches the If-Modified-Since HTTP request header, the Leaf will return a 304 Not Modified HTTP response that will prompt the HTTP client to use its cache; this also prevents the Leaf from serving the entire markup for the resource URI saving bandwidth and response time.
+A Leaf provides built-in support for HTTP caching using the resourceβs last modified date. When you specify a last modified date, Leaf tells the HTTP client the date and time the current resource was last modified. The HTTP client will then send a If-Modified-Since header with each subsequent HTTP request for the given resource URI.
+
+If the last modification date you specify matches the If-Modified-Since HTTP request header, the Leaf will return a 304 Not Modified HTTP response that will prompt the HTTP client to use its cache; this also prevents the Leaf from serving the entire markup for the resource URI saving bandwidth and response time.
Setting a last modified date with Leaf is very simple. You only need to invoke the Leafβs lastModified() method in your route callback passing in a UNIX timestamp of the last modification date for the given resource. Be sure the lastModified() methodβs timestamp updates along with the resourceβs last modification date; otherwise, the browser client will continue serving its outdated cache.
diff --git a/src/docs/http/cookies.md b/src/docs/http/cookies.md
index 98180b6f..ff76742d 100644
--- a/src/docs/http/cookies.md
+++ b/src/docs/http/cookies.md
@@ -47,7 +47,7 @@ The `withCookie()` method takes in 3 parameters:
## Setting Cookies with Options
-`response()->withCookie()` is a simple way to set cookies, but it only works for the most basic use cases. If you need a more powerful way to set cookies, you can use the `set()` method. It takes in 3 parameters:
+`response()->withCookie()` is a simple way to set cookies, but it only works for the most basic use cases. If you need more control over how a cookie is set, you can use the `set()` method. It takes in 3 parameters:
- cookie name
- cookie value
@@ -55,7 +55,7 @@ The `withCookie()` method takes in 3 parameters:
```php
cookie()->set('name', 'Fullname', [
- 'expire' => time() + 3600,
+ 'expires' => time() + 3600,
'path' => '/',
'domain' => 'example.com',
'secure' => true,
@@ -66,6 +66,38 @@ cookie()->set('name', 'Fullname', [
The `set()` method allows you to set cookies with more advanced options like expiration time, path, domain, secure, httponly, and samesite which are all optional.
+You can also set multiple cookies at once by passing an array of names and values. The options you pass apply to every cookie in the array.
+
+```php
+cookie()->set([
+ 'name' => 'Fullname',
+ 'age' => 20
+], '', [
+ 'path' => '/',
+ 'secure' => true
+]);
+```
+
+If you just need a cookie with an expiry time, `simpleCookie()` takes a name, a value and an expiry which can be a timestamp or a `strtotime()`-style string like `'7 days'` or `'1 hour'`. It defaults to 7 days if you don't pass one.
+
+```php:no-line-numbers
+cookie()->simpleCookie('name', 'Fullname', '7 days');
+```
+
+## Setting Cookie Defaults
+
+Instead of repeating options like `path` and `domain` on every cookie, you can set them once with `setDefaults()`. Any option you don't pass to `set()` falls back to these defaults.
+
+```php
+cookie()->setDefaults([
+ 'path' => '/',
+ 'secure' => true,
+ 'httponly' => true
+]);
+```
+
+This matters for deleting cookies too: a cookie is only removed if it's deleted with the same path and domain it was set with, and Leaf uses your configured defaults when deleting. Setting your defaults once (especially `path => '/'`) keeps setting and deleting consistent.
+
## Reading Cookies
When you send cookies to the client, they are stored in your users' browsers and automatically sent back to your app on every request. You can read these cookies using the `cookies()` method on the incoming request.
@@ -114,5 +146,5 @@ cookie()->delete('name');
You may also choose to delete all your cookies, for instance if you detect an authentication or authorization breech in your application. You can do this using the `deleteAll()` method on Leaf cookies.
```php:no-line-numbers
-cookie()->deteleAll();
+cookie()->deleteAll();
```
diff --git a/src/docs/http/cors.md b/src/docs/http/cors.md
index 56d7de74..720213fc 100644
--- a/src/docs/http/cors.md
+++ b/src/docs/http/cors.md
@@ -2,53 +2,30 @@
-
-
-From Wikipedia, Cross-origin resource sharing (CORS) is a mechanism that allows restricted resources on a web page to be accessed from another domain outside the domain from which the first resource was served.
-
-::: details What is CORS?
-
-Cross-Origin Resource Sharing or CORS is a mechanism that allows browsers to request data from 3rd party URLs (or origins) and is a common pain point for web developers. Learn the basics of CORS in 100 seconds from Fireship.io.
-
-
-
-:::
-
-Since CORS is a common pain point for web developers, Leaf provides a first-party integration that takes care of all the heavy lifting for you.
-
-
-
-
-
- Using Leaf MVC?
-
-
- We've crafted a specialized guide for CORS in Leaf MVC. While it's similar to the base usage in Leaf, it's more detailed and tailored for Leaf MVC.
-
-
-
+
+
+
+
HTTP access
+
Let the right frontends talk to your API.
+
Leaf CORS gives you a small, explicit configuration layer for browser access, preflight requests, credentials, and allowed origins.
-
-
+
+
+
app()->cors([
+
'origin' => ['https://app.example.com'],
+
'methods' => ['GET', 'POST'],
+
]);
+
+
+
Using Leaf MVC?
+
Use the MVC CORS guide when your configuration lives with the rest of your application environment.
+
+
+CORS is the browser security layer that decides which origins can read responses from your app. Since CORS is a common pain point for web developers, Leaf provides a first-party integration that takes care of the repetitive setup for you.
## Setting Up
@@ -98,25 +75,32 @@ app()->cors([
This will only allow users from `http://example.com` and `http://example.org` to access your app using the `GET` and `POST` methods. You can find a list of all available options below.
-If you want to allow access to all subdomains of a domain, you can use just the website domain as the origin without the `http://` or `https://`.
+Origins are matched exactly, so each configured origin must be a full origin including the scheme, like `https://example.com`. A bare domain like `example.com` or any other partial value will not match. Matching the full origin is what keeps look-alike domains from being treated as yours.
+
+If you want to allow a whole family of origins, such as every subdomain of a site, you can use a regular expression written as a string:
```php
app()->cors([
- 'origin' => 'example.com',
+ 'origin' => '/^https:\/\/(.*\.)?example\.com$/',
]);
```
-This will allow `http://example.com`, `https://example.com`, `http://www.example.com`, and `https://some-subdomain.example.com` to access your app. Of course, you can also use a regular expression to match multiple domains. You can find a full list of options below.
+This will allow `https://example.com` and any of its subdomains, like `https://app.example.com`. You can also mix exact origins and regex strings in an array. When a specific origin matches, Leaf reflects the request's origin in the `Access-Control-Allow-Origin` header; with `origin` set to `'*'`, the header is a literal `*`.
+
+::: tip Credentials and origins
+If you set `credentials` to `true`, pair it with explicit origins rather than `'*'`. Browsers reject credentialed responses that allow every origin.
+:::
## Configuration Options
The `cors()` method takes in an array of options. Here are the available options:
- `origin`: Configures the **Access-Control-Allow-Origin** CORS header. Possible values:
- * `String` - set `origin` to a specific origin. For example if you set it to `"http://example.com"` only requests from "http://example.com" will be allowed.
- * `RegExp` - set `origin` to a regular expression pattern which will be used to test the request origin. If it's a match, the request origin will be reflected. For example the pattern `/example\.com$/` will reflect any request that is coming from an origin ending with "example.com".
- * `Array` - set `origin` to an array of valid origins. Each origin can be a `String` or a `RegExp`. For example `["http://example1.com", /\.example2\.com$/]` will accept any request from "http://example1.com" or from a subdomain of "example2.com".
- * `Function` - set `origin` to a function implementing some custom logic. The function takes the request origin as the first parameter and a callback (called as `callback(err, origin)`, where `origin` is a non-function value of the `origin` option) as the second.
+ * `String` - set `origin` to a specific origin, including the scheme. For example if you set it to `"https://example.com"` only requests from "https://example.com" will be allowed. Origins are matched exactly; partial values like `"example.com"` will not match.
+ * `Regex string` - set `origin` to a regular expression written as a string, which will be tested against the request origin. If it matches, the request origin will be reflected. For example `'/^https:\/\/(.*\.)?example\.com$/'` will allow "https://example.com" and any of its subdomains.
+ * `Array` - set `origin` to an array of valid origins. Each origin can be an exact origin or a regex string. For example `['https://example1.com', '/^https:\/\/(.*\.)?example2\.com$/']` will accept requests from "https://example1.com" or from "example2.com" and its subdomains.
+
+- `credentials`: Configures the **Access-Control-Allow-Credentials** CORS header. Set to `true` to pass the header, otherwise it is omitted. When enabling credentials, use explicit origins rather than `'*'`, since browsers reject credentialed responses that allow every origin.
- `methods`: Configures the **Access-Control-Allow-Methods** CORS header. Expects a comma-delimited string (ex: 'GET,PUT,POST') or an array (ex: `['GET', 'PUT', 'POST']`).
@@ -124,13 +108,11 @@ The `cors()` method takes in an array of options. Here are the available options
- `exposedHeaders`: Configures the **Access-Control-Expose-Headers** CORS header. Expects a comma-delimited string (ex: 'Content-Range,X-Content-Range') or an array (ex: `['Content-Range', 'X-Content-Range']`). If not specified, no custom headers are exposed.
-- `credentials`: Configures the **Access-Control-Allow-Credentials** CORS header. Set to `true` to pass the header, otherwise it is omitted.
-
- `maxAge`: Configures the **Access-Control-Max-Age** CORS header. Set to an integer to pass the header, otherwise it is omitted.
- `preflightContinue`: Pass the CORS preflight response to the next handler.
-- `optionsSuccessStatus`: Provides a status code to use for successful `OPTIONS` requests, since some legacy browsers (IE11, various SmartTVs) choke on `204`.
+- `optionsSuccessStatus`: The status code returned for successful preflight `OPTIONS` requests, since some legacy browsers (IE11, various SmartTVs) choke on `204`. Set it to `200` if you need to support those clients.
The default configuration is the equivalent of:
diff --git a/src/docs/http/cors/mvc.md b/src/docs/http/cors/mvc.md
index 172b4fc3..dcf50eeb 100644
--- a/src/docs/http/cors/mvc.md
+++ b/src/docs/http/cors/mvc.md
@@ -7,25 +7,24 @@ prev: false
-
-
-From Wikipedia, Cross-origin resource sharing (CORS) is a mechanism that allows restricted resources on a web page to be accessed from another domain outside the domain from which the first resource was served.
-
-::: details What is CORS?
-
-Cross-Origin Resource Sharing or CORS is a mechanism that allows browsers to request data from 3rd party URLs (or origins) and is a common pain point for web developers. Learn the basics of CORS in 100 seconds from Fireship.io.
-
-
-
-:::
-
-Since CORS is a common pain point for web developers, Leaf provides a first-party integration that takes care of all the heavy lifting for you.
+
+
+
+
MVC HTTP access
+
Configure browser access from your app environment.
+
Leaf MVC wires CORS into the app for you, then lets production apps tighten allowed origins, methods, and headers through environment config or a published config file.
+
+
+
+
CORS_ALLOWED_ORIGINS='https://app.example.com'
+
CORS_ALLOWED_METHODS='GET,POST'
+
leaf config:publish cors
+
+
+
+
+
+CORS is the browser security layer that decides which origins can read responses from your app. Since CORS is a common pain point for web developers, Leaf provides a first-party integration that takes care of the repetitive setup for you.
## Setting Up
@@ -50,12 +49,18 @@ After installing the CORS module, Leaf MVC will automatically set up CORS to han
Most of the configuration options can be configured using environment variables. Here are the available options:
```txt [.env]
-CORS_ALLOWED_ORIGINS='/\.example\.com$/'
+CORS_ALLOWED_ORIGINS='https://app.example.com'
CORS_ALLOWED_METHODS='GET,HEAD,PUT,PATCH,POST,DELETE'
CORS_ALLOWED_HEADERS='*'
```
-While this is easier and allows you to easily configure different environments, it can sometimes be limiting for example when you want to return a function for dynamically set your allowed origins. For this reason, you can publish your CORS configuration using the command below:
+Origins are matched exactly, so each configured origin must be a full origin including the scheme, like `https://app.example.com`. Partial values like `example.com` will not match. To allow a whole family of origins, such as every subdomain of a site, use a regular expression written as a string:
+
+```txt [.env]
+CORS_ALLOWED_ORIGINS='/^https:\/\/(.*\.)?example\.com$/'
+```
+
+While this is easier and allows you to easily configure different environments, it can sometimes be limiting, for example when you want to allow an array that mixes exact origins and regex strings. For this reason, you can publish your CORS configuration using the command below:
```bash:no-line-numbers
leaf config:publish cors
@@ -74,24 +79,22 @@ return [
|
| Configures the Access-Control-Allow-Origin CORS header. Possible values:
|
- | * String - set origin to a specific origin. For example if
- | you set it to "http://example.com" only requests from
- | "http://example.com" will be allowed.
- |
- | * RegExp - set origin to a regular expression pattern which will be
- | used to test the request origin. If it's a match, the request origin
- | will be reflected. For example the pattern /example\.com$/ will reflect
- | any request that is coming from an origin ending with "example.com".
+ | * String - set origin to a specific origin, including the scheme.
+ | For example if you set it to "https://example.com" only requests
+ | from "https://example.com" will be allowed. Origins are matched
+ | exactly; partial values like "example.com" will not match.
|
- | * Array - set origin to an array of valid origins. Each origin can be a String
- | or a RegExp. For example ["http://example1.com", /\.example2\.com$/] will
- | accept any request from "http://example1.com" or from
- | a subdomain of "example2.com".
+ | * Regex string - set origin to a regular expression written as a
+ | string, which will be tested against the request origin. If it
+ | matches, the request origin will be reflected. For example
+ | '/^https:\/\/(.*\.)?example\.com$/' will allow
+ | "https://example.com" and any of its subdomains.
|
- | * Function - set origin to a function implementing some custom
- | logic. The function takes the request origin as the first parameter
- | and a callback (called as callback(err, origin), where origin is a
- | non-function value of the origin option) as the second.
+ | * Array - set origin to an array of valid origins. Each origin can
+ | be an exact origin or a regex string. For example
+ | ['https://example1.com', '/^https:\/\/(.*\.)?example2\.com$/']
+ | will accept requests from "https://example1.com" or from
+ | "example2.com" and its subdomains.
|
*/
'origin' => _env('CORS_ALLOWED_ORIGINS', '*'),
@@ -185,10 +188,9 @@ return [
The `cors()` method takes in an array of options. Here are the available options:
- `origin`: Configures the **Access-Control-Allow-Origin** CORS header. Possible values:
- * `String` - set `origin` to a specific origin. For example if you set it to `"http://example.com"` only requests from "http://example.com" will be allowed.
- * `RegExp` - set `origin` to a regular expression pattern which will be used to test the request origin. If it's a match, the request origin will be reflected. For example the pattern `/example\.com$/` will reflect any request that is coming from an origin ending with "example.com".
- * `Array` - set `origin` to an array of valid origins. Each origin can be a `String` or a `RegExp`. For example `["http://example1.com", /\.example2\.com$/]` will accept any request from "http://example1.com" or from a subdomain of "example2.com".
- * `Function` - set `origin` to a function implementing some custom logic. The function takes the request origin as the first parameter and a callback (called as `callback(err, origin)`, where `origin` is a non-function value of the `origin` option) as the second.
+ * `String` - set `origin` to a specific origin, including the scheme. For example if you set it to `"https://example.com"` only requests from "https://example.com" will be allowed. Origins are matched exactly; partial values like `"example.com"` will not match.
+ * `Regex string` - set `origin` to a regular expression written as a string, which will be tested against the request origin. If it matches, the request origin will be reflected. For example `'/^https:\/\/(.*\.)?example\.com$/'` will allow "https://example.com" and any of its subdomains.
+ * `Array` - set `origin` to an array of valid origins. Each origin can be an exact origin or a regex string. For example `['https://example1.com', '/^https:\/\/(.*\.)?example2\.com$/']` will accept requests from "https://example1.com" or from "example2.com" and its subdomains.
- `methods`: Configures the **Access-Control-Allow-Methods** CORS header. Expects a comma-delimited string (ex: 'GET,PUT,POST') or an array (ex: `['GET', 'PUT', 'POST']`).
@@ -196,13 +198,13 @@ The `cors()` method takes in an array of options. Here are the available options
- `exposedHeaders`: Configures the **Access-Control-Expose-Headers** CORS header. Expects a comma-delimited string (ex: 'Content-Range,X-Content-Range') or an array (ex: `['Content-Range', 'X-Content-Range']`). If not specified, no custom headers are exposed.
-- `credentials`: Configures the **Access-Control-Allow-Credentials** CORS header. Set to `true` to pass the header, otherwise it is omitted.
+- `credentials`: Configures the **Access-Control-Allow-Credentials** CORS header. Set to `true` to pass the header, otherwise it is omitted. When enabling credentials, use explicit origins rather than `'*'`, since browsers reject credentialed responses that allow every origin.
- `maxAge`: Configures the **Access-Control-Max-Age** CORS header. Set to an integer to pass the header, otherwise it is omitted.
- `preflightContinue`: Pass the CORS preflight response to the next handler.
-- `optionsSuccessStatus`: Provides a status code to use for successful `OPTIONS` requests, since some legacy browsers (IE11, various SmartTVs) choke on `204`.
+- `optionsSuccessStatus`: The status code returned for successful preflight `OPTIONS` requests, since some legacy browsers (IE11, various SmartTVs) choke on `204`. Set it to `200` if you need to support those clients.
The default configuration is the equivalent of:
@@ -222,7 +224,7 @@ The default configuration is the equivalent of:
## What to read next
-CORS is a very important part of web development, especially when you're working with APIs. Just as this module improves your experience with Leaf, there are other modules that can help you build better apps with Leaf:
+Now that CORS is handled, here are a few other parts of Leaf and Leaf MVC worth a look:
flash('object');
$array = request()->flash('array');
```
-The item will be removed from the session after it has been displayed.
+Flash items come back exactly as they were set, without any HTML escaping, so flashed arrays like old form input are returned unchanged. The item will be removed from the session after it has been displayed.
## Manually removing a flash item
diff --git a/src/docs/http/headers.md b/src/docs/http/headers.md
index 5b0858f8..843e5fbd 100644
--- a/src/docs/http/headers.md
+++ b/src/docs/http/headers.md
@@ -23,7 +23,7 @@ Note that all headers are automatically sanitized by default. If you want to get
::: code-group
```php:no-line-numbers [Request Class]
-$allHeaders = request()->headers(safeHeaders: false);
+$allHeaders = request()->headers(safeData: false);
```
```php:no-line-numbers [Headers Class]
diff --git a/src/docs/http/request.md b/src/docs/http/request.md
index bd4b1bcd..756555ca 100644
--- a/src/docs/http/request.md
+++ b/src/docs/http/request.md
@@ -44,7 +44,9 @@ $item = $app->request()->get('item');
:::
-The `get()` method works for all types of request data, including query parameters, form data, files, and JSON data so there's no need to worry about the type of data you're working with. You can also get multiple values at once by passing an array of keys to the `get()` method. This is especially useful when you're working with form data or JSON data where users can send any random data they want. In such cases, you can use the `get()` method to get only the data you're interested in.
+The `get()` method works for all types of request data, including query parameters, form data, files, and JSON data so there's no need to worry about the type of data you're working with.
+
+You can also get multiple values at once by passing an array of keys to the `get()` method. This is especially useful when you're working with form data or JSON data where users can send any random data they want. In such cases, you can use the `get()` method to get only the data you're interested in.
```php:no-line-numbers
$data = request()->get(['name', 'email']);
@@ -54,7 +56,9 @@ $data = request()->get(['name', 'email']);
### Data Sanitization
-Leaf automatically sanitizes all data coming into your application. This means that you don't have to worry about users sending malicious data to your application since Leaf will automatically clean it up for you. This lets you focus on building your application without worrying about security. There are some cases where you might want to disable this behavior, such as when you're working with raw data or when you're building an API that needs to accept any kind of data. In such cases, you can disable data sanitization by passing `false` as a second parameter to the `get()` method.
+Leaf automatically sanitizes all data coming into your application. This means that you don't have to worry about users sending malicious data to your application since Leaf will automatically clean it up for you. This lets you focus on building your application without worrying about security.
+
+There are some cases where you might want to disable this behavior, such as when you're working with raw data or when you're building an API that needs to accept any kind of data. In such cases, you can disable data sanitization by passing `false` as a second parameter to the `get()` method.
```php:no-line-numbers
$data = request()->get('data', false);
@@ -64,6 +68,12 @@ $data = request()->get('data', false);
Disabling data sanitization can expose your application to security vulnerabilities. Only disable data sanitization when you're sure that the data you're working with is safe.
:::
+One important thing to know: sanitization works by HTML-escaping values, so `it's` becomes `it's`. Escaped text is meant for output, not storage. If you store it, your data is corrupted for any frontend that escapes at render time, and React, Vue, and Blade all do, so your users end up seeing `it's` on screen. For values headed to your database, pass `false` as the second argument and rely on parameterized queries plus render-time escaping to keep things safe:
+
+```php:no-line-numbers
+$bio = request()->get('bio', false); // store the raw value
+```
+
## Conditionally getting request data
Sometimes you might want to get a value from the request only if it exists. You can use the `try()` method to do this. The `try()` method takes an array of keys as an argument and returns only the values that exist in the request.
@@ -110,13 +120,23 @@ Every time you call the `body()` method, Leaf will sanitize the data in the requ
$data = request()->body(false);
```
+If you prefer working with objects instead of arrays (say you're passing request data straight into typed code), `object()` returns the same data as an object, nested structures included (lists stay arrays). It takes the same sanitization parameter as `body()`:
+
+```php:no-line-numbers
+$data = request()->object();
+
+// $data->name, $data->profile->city, ...
+```
+
## Request type specific methods
We mentioned earlier that there are different types of HTTP requests, such as `GET`, `POST`, `PUT`, `DELETE`, and more. Leaf provides methods that you can use to access data specific to each type of request. We'll cover the most common ones here.
### GET requests
-GET requests are the most common type of request and are used to access web pages, images, and other resources. Unlike other types of requests, GET requests send data in the URL as query parameters. You've probably seen URLs like `https://example.com/route?name=John&age=25`. In this case, the query parameters are `name` and `age`. We can get these query parameters using the `query()` method. It takes in 2 parameters:
+GET requests are the most common type of request and are used to access web pages, images, and other resources. Unlike other types of requests, GET requests send data in the URL as query parameters. You've probably seen URLs like `https://example.com/route?name=John&age=25`. In this case, the query parameters are `name` and `age`.
+
+We can get these query parameters using the `query()` method. It takes in 2 parameters:
- The key of the query parameter
- A default value to return if the query parameter doesn't exist (optional)
@@ -150,7 +170,9 @@ $file = request()->files('file');
## Saving files from the request
-When a user uploads a file to your application, like a profile picture, you can save the file to your server for later use. In Leaf, there's an `upload()` on Leaf's request object that helps you easily manage file uploads. This method lets you move the file to the correct folder on your server, ensuring it's stored properly. It takes in 3 parameters:
+When a user uploads a file to your application, like a profile picture, you can save the file to your server for later use. In Leaf, there's an `upload()` on Leaf's request object that helps you easily manage file uploads.
+
+This method lets you move the file to the correct folder on your server, ensuring it's stored properly. It takes in 3 parameters:
- The name of the file in the request
- The directory to save the file to
@@ -183,6 +205,25 @@ $uploadInfo = request()->upload('profile_pic', './uploads', [
]);
```
+Besides `name` and `rename`, the config array also accepts `overwrite` to replace an existing file with the same name, `maxSize` to cap the file size in bytes, and `validate` which turns on type checking using the `allowedTypes` and `allowedExtensions` keys.
+
+On a successful upload, `upload()` returns an array describing the saved file:
+
+```php
+[
+ 'name' => 'profile.png', // the saved file name
+ 'size' => 44075, // the file size in bytes
+ 'type' => 'image', // the detected file type
+ 'path' => 'uploads/profile.png', // where the file was saved
+ 'extension' => 'png', // the file extension
+ 'url' => 'https://example.com/uploads/profile.png', // a public URL for the file
+]
+```
+
+The returned `url` is built from your `APP_URL` env value, so if `APP_URL` may not be set in your environment, building your own URL from the returned `name` is the more reliable option.
+
+If the upload fails, `upload()` returns `false`. Failures from the filesystem layer, like a file going over `maxSize` or failing type validation, are reported in `\Leaf\FS\File::errors()` rather than `request()->errors()`, so be sure to check there when an upload returns `false`.
+
## Request Headers
Headers contain information about the request that can be used to make decisions in your application. You can use the `headers()` method to pull the header information from the request.
@@ -201,7 +242,7 @@ One thing to note is that all headers are sanitized automatically by Leaf. If yo
```php
$allHeaders = request()->headers(
- safeHeaders: false
+ safeData: false
);
$contentType = request()->headers(
@@ -212,7 +253,9 @@ $contentType = request()->headers(
## Validating Request Data
-When building user-facing applications, there's no guarantee that users will always send the correct data to your application. In most cases, users will send incorrect data, either by mistake or on purpose. This can lead to errors in your application and can even expose your application to security vulnerabilities. To prevent this, you can use Leaf's built-in validation library to validate the data coming into your application. Let's see how it works:
+When building user-facing applications, there's no guarantee that users will always send the correct data to your application. In most cases, users will send incorrect data, either by mistake or on purpose. This can lead to errors in your application and can even expose your application to security vulnerabilities.
+
+To prevent this, you can use Leaf's built-in validation library to validate the data coming into your application. Let's see how it works:
```php{2-6}
app()->post('/example/register', function() {
@@ -228,7 +271,9 @@ app()->post('/example/register', function() {
});
```
-In the example above, we're validating the data coming into our application. We're checking if the `name` field is a text, if the `email` field is a valid email, and if the `password` field is at least 8 characters long. If any of these validations fail, the `validate()` method will return `false` and you can get the errors using the `errors()` method. You can find the full list of validation rules [here](/docs/data/validation).
+In the example above, we're validating the data coming into our application. We're checking if the `name` field is a text, if the `email` field is a valid email, and if the `password` field is at least 8 characters long.
+
+If any of these validations fail, the `validate()` method will return `false` and you can get the errors using the `errors()` method. You can find the full list of validation rules [here](/docs/data/validation).
## Client IP & Geo Location
@@ -265,7 +310,7 @@ $location = request()->getUserLocation();
// 'continentCode' => 'NA',
```
-Keep in mind that the free tier of the ip-api service has a limit of 45 requests per minute from an IP address, you can check out other implementations for more robust solutions.
+Keep in mind that the free tier of the ip-api service has a limit of 45 requests per minute from an IP address, so you may want a paid or self-hosted alternative if you need more.
### Pass in a custom IP
diff --git a/src/docs/http/response.md b/src/docs/http/response.md
index 3c674b40..5bf523c0 100644
--- a/src/docs/http/response.md
+++ b/src/docs/http/response.md
@@ -20,7 +20,9 @@ In the above example, the response is a JSON object with a message key and a val
- Headers: `Server: nginx/1.14.0 (Ubuntu)`, `Content-Type: application/json; charset=UTF-8`, `Content-Length: 27`, `Connection: keep-alive`
- Body: `{"message":"Hello, world!"}`
-This is true for all responses. They all have a status line, headers, and a body. The status line tells the client if the request was successful or not. The headers provide additional information about the response. The body contains the actual data that the client requested.
+This is true for all responses. They all have a status line, headers, and a body.
+
+The status line tells the client if the request was successful or not. The headers provide additional information about the response. The body contains the actual data that the client requested.
## Creating Responses
@@ -149,7 +151,9 @@ $app->response()->page('path/to/file.html');
## Error responses
-During production development, you most likely would not want to throw exceptions to the user. Instead, you would want to return a nice error message. Leaf provides a simple way to do this using the `exit()` or `die()` method. This method outputs an error message and exits your application immediately so that nothing else is executed. It takes in 2 parameters:
+During production development, you most likely would not want to throw exceptions to the user. Instead, you would want to return a nice error message. Leaf provides a simple way to do this using the `exit()` or `die()` method.
+
+This method outputs an error message and exits your application immediately so that nothing else is executed. It takes in 2 parameters:
- the error message to output
- an optional status code (defaults to 500/Internal Server Error)
@@ -174,12 +178,13 @@ $app->response()->die('An error occurred', 500);
If you pass a string as the first parameter, Leaf will automatically convert it to a markup response. If you pass an array, Leaf will automatically convert it to a JSON response.
-## Templating NEW
+## Templating
-Leaf has support for a wide range of templating engines plus any other templating engine you might want to use. Once you have a view engine installed and set up, you can use the `view()` or `render()` method to render views. This method accepts 2 parameters:
+Leaf has support for a wide range of templating engines plus any other templating engine you might want to use. Once you have a view engine installed and set up, you can use the `view()` or `render()` method to render views. This method accepts 3 parameters:
- the name of the view to render
- an array of data to pass to the view
+- an optional HTTP status code (defaults to 200), handy for error pages
::: code-group
@@ -191,6 +196,8 @@ response()->view('home', [
response()->render('home', [
'name' => 'Michael'
]);
+
+response()->render('errors.404', [], 404); // render with a status code
```
```php:no-line-numbers [Leaf Instance]
@@ -256,6 +263,14 @@ $app->response()->download('path/to/file.pdf', 'new-filename.pdf', 200);
:::
+Downloads are streamed in chunks, so memory stays flat no matter the file size: a 5GB file doesn't need 5GB of memory. Downloads also honor HTTP `Range` requests automatically : browsers and download managers can pause/resume and fetch files in parallel segments, and Leaf answers with proper `206 Partial Content` responses. You don't have to do anything, it's on for every download:
+
+```bash:no-line-numbers
+# a client resuming an interrupted download from byte 1000000
+curl -H "Range: bytes=1000000-" https://yourapp.com/files/report.zip
+# β 206 Partial Content, Content-Range: bytes 1000000-4999999/5000000
+```
+
### No content responses
These responses are used when you don't want to return any content to the user. You can create a no content response using the `noContent()` method. It also automatically sets the status code to 204/No Content.
@@ -378,7 +393,7 @@ Leaf allows you to set cookies for your response using the `withCookie()` method
```php
response()
- ->withCookie('name', 'Michael', '1 day')
+ ->withCookie('name', 'Michael', time() + 86400)
->json('...');
```
diff --git a/src/docs/http/session.md b/src/docs/http/session.md
index 8112ae80..aac5f13a 100644
--- a/src/docs/http/session.md
+++ b/src/docs/http/session.md
@@ -1,6 +1,8 @@
# Session
-Normally, when you visit a website, each time you click on something, the website treats it like a new visit. This is because HTTP is stateless. This means that the website doesn't remember anything about you and other users from one request to the next. This is where sessions come in.
+Normally, when you visit a website, each time you click on something, the website treats it like a new visit. This is because HTTP is stateless.
+
+This means that the website doesn't remember anything about you and other users from one request to the next. This is where sessions come in.
Sessions fix this problem by allowing the website to "remember" things about you, like if you're logged in or what's in your shopping cart.
@@ -82,6 +84,8 @@ To get session data, you can use the `get()` method. This method takes three par
- a default value to return if the key doesn't exist
- a boolean to determine if the data should be sanitized (default is `true`)
+Session values are stored exactly as you set them. Sanitization happens on read: `get()` HTML-escapes the returned data by default, and passing `false` as the third parameter returns the raw value instead.
+
```php
$firstName = session()->get('firstName');
$firstName = session()->get('firstName', 'John');
@@ -173,7 +177,7 @@ You can easily add a new item to the array using the same `set()` method:
session()->set('user.location', 'Everywhere');
```
-This will add a location key to the user array in the session. This saves you from having to get the user array, adding the location key, and setting it back to the session.
+This will add a location key to the user array in the session. This saves you from having to get the user array, adding the location key, and setting it back to the session. Dot notation works at any depth, so keys like `user.preferences.notifications.email` are fine too.
It also works for the other methods like `get()`, `has()` and `delete()`.
diff --git a/src/docs/index.md b/src/docs/index.md
index b5c85d60..41bea9eb 100644
--- a/src/docs/index.md
+++ b/src/docs/index.md
@@ -1,220 +1,36 @@
-# Meet Leaf PHP
+---
+aside: false
+---
+
+# Start building with Leaf
-Leaf is PHP made simpleβelegant, intuitive, and easy to pick up, with lightweight tools that help makers build, ship, and scale effortlessly.
-
-```php
-get('/', function () {
- response()->json(['message' => 'Hello World!']);
-});
-
-app()->run();
-```
-
-Leaf handles the heavy lifting so you can focus on building. It provides a simple routing system, powerful middleware support, seamless database integration, and a whole lot more.
-
-As we love to say, "Writing code should be simple and fun, and that's what Leaf is all about."
-
-## Why Leaf?
-
-Most PHP frameworks are complex, slow, and opinionated. Leaf is differentβit's built for makers.
-
-- π Beginner-friendly β Get started in minutes with just basic PHP knowledge.
-- β‘ Lightweight & fast β A minimal core with high performance and low memory usage.
-- π οΈ Built for makers β Simple APIs, class-free initializers, and global functions that let you focus on shipping.
-- π Seamless integration β Works effortlessly with any library or frameworkβno complex setups required.
-- π Scales with you β Everything you needβrouting, database tools, authentication, and moreβbut stays unopinionated, letting you pick and choose what fits your project.
-
-## Creating a new app
-
-Leaf is built to be incrementally adoptable: use it as a lightweight core for small to medium apps, or scale up with [Leaf MVC](/docs/mvc/) for more structure in complex applications. No matter your stack, Leaf stays simple, fast, and developer-friendlyβso you can build and ship with ease.
-
-::: details Technical Requirements
-
-Before you start with Leaf, verify that your system has the following installed:
-
-- PHP v7.4 or higher
-- Composer (for package management)
-- [Leaf CLI](/docs/cli/) (optional but recommended for easier app management)
-
-::: details Don't have PHP & Composer installed?
-
-- Beyond Code released an amazing tool called [Laravel Herd](https://herd.laravel.com/) that provides a quick and easy way to set up a local PHP development environment for Mac and Windows. It's a great way to get started with PHP if you don't have it installed yet.
-
-- Another way to install PHP and Composer without any hassle is to use [php.new](https://php.new/) which was created by Beyond Code. It's a quick way to get started on Windows, Linux and Mac with just one command.
-
-- A more traditional way on Windows, Linux and Mac, you can use [Xampp](https://www.apachefriends.org/), which is a free and open-source cross-platform web server solution stack package developed by Apache Friends, consisting mainly of the Apache HTTP Server, MariaDB database, and interpreters for scripts written in the PHP and Perl programming languages.
-
-:::
-
-Once you install PHP and Composer, you can proceed with the installation of Leaf CLI:
-
-```bash:no-line-numbers
-composer global require leafs/cli -W
-```
-
-### Building your first app
-
-After setting up Leaf CLI, you can create a new Leaf app using the `create` command:
-
-```bash:no-line-numbers
-leaf create
-```
-
-This will walk you through a quick setup process where you can select the kind of application you want to build. You can find more options in the [CLI documentation](/docs/cli/).
-
-Once your project is generated, you can run it using the `serve` command:
-
-```bash:no-line-numbers
-leaf serve
-```
-
-That's it! You're now ready to start building with Leaf. π
-
-
-
-## Building with Leaf
-
-No project is the same, why should your tools be? Leaf is designed to be flexible and adaptable, so you can build your way. Choose your path and start building with Leaf:
-
-
-
-
-
-
-
- Basic Leaf App
-
-
- Use Leaf as a micro-framework to build simple apps and APIs.
-
-
-
-
-
-
-
-
-
-
-
-
-
- Leaf MVC App
-
-
- Add an MVC structure on top of Leaf for more complex apps.
-
-
-
-
-
-
-
-
-
-
-
-
-
- MVC for APIs
-
-
- Build APIs with a structured approach for better organization.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/docs/modules.md b/src/docs/modules.md
index 7b4e16e4..5aac4bb5 100644
--- a/src/docs/modules.md
+++ b/src/docs/modules.md
@@ -1,66 +1,173 @@
# Modules
-Modules are the building blocks of Leaf. They are independent pieces of Leaf's functionality available for use in your app. They are designed to be simple, easy to use, framework-agnostic, and can be used in any PHP project with nearly zero configuration.
+
+
+
+
+
+
Composable PHP
+
Add the parts your app needs, exactly when it needs them.
+
Modules are independent pieces of Leaf functionality. They stay small, framework-agnostic, easy to install, and ready to use in Leaf apps, Leaf MVC projects, or even plain PHP projects.
+
+
+
+
+
+
+
+ install
+
+
+
$ leaf install auth db mail
+
$ leaf install cors cache queue
+
modules wire into the Leaf ecosystem
+
+
+
+
+
Why modules
+
+
+
Start small
+
Use the core framework first, then add capabilities as your product grows.
+
+
+
Stay explicit
+
Installed modules make your app's capabilities obvious to people and AI tools.
+
+
+
Avoid lock-in
+
Most modules are regular Composer packages that fit normal PHP workflows.
+
+
+
+
+
## Installing Modules
-Modules are just like regular PHP packages. You can install them using Composer. To install a module, run:
+Modules are Composer packages, so you can install them with Composer:
```bash:no-line-numbers
composer require leafs/
```
-If you're using Leaf CLI, you can install a module without the `leafs/` prefix:
+If you're using Leaf CLI, you can install official Leaf modules without the `leafs/` prefix:
```bash:no-line-numbers
leaf install
```
+You can also install multiple modules at once:
+
+```bash:no-line-numbers
+leaf install auth db mail
+```
+
+
+
+ 01 / Leaf workflow
+ Leaf CLI
+ Best for Leaf projects. Short names work for first-party modules, and the command keeps the workflow consistent.
+
+
+ 02 / PHP workflow
+ Composer
+ Best when you want the raw PHP package manager flow or are installing modules outside a Leaf app.
+
+
+
## Using Modules
-Most modules integrate directly into Leaf, so you can use them with Leaf's functional mode. This gives you a little more performance and flexibility. The documentation for each module covers everything you need to know about using the module.
+Most modules integrate directly into Leaf's functional style, so you can use focused helpers without building your own wiring layer.
+
+```php:no-line-numbers
+auth()->login($credentials);
+db()->select('users')->where('id', 1)->first();
+response()->json(['ok' => true]);
+```
+
+In Leaf MVC, modules can also work through config files, controllers, models, services, and other structured app pieces.
+
+## Common Module Groups
+
+
+
+ 01 / Foundation
+ Product basics
+ Auth, sessions, cookies, validation, CSRF, CORS, request, and response helpers.
+
+
+ 02 / Storage
+ Data and state
+ Database, Redis, cache, queues, files, sitemaps, and storage integrations.
+
+
+ 03 / Interface
+ Frontend and views
+ Blade, BareUI, Inertia, Vite, frontend asset builds, and view rendering.
+
+
+ 04 / Operations
+ Production features
+ Mail, billing, testing, logging, encryption, and deployment helpers.
+
+
+
+## Modules and AI Context
+
+Modules make your app easier for assistants to understand because installed packages and their configuration are recorded in `.leaf/CONTEXT.md`. Agents inside the project read that shared memory alongside the filesystem and keep it aligned as the app changes.
+
+If you are sharing the app with an external assistant that cannot access the folder, generate a compact handoff after adding modules:
+
+```bash:no-line-numbers
+leaf context
+```
+
+The command prints a minified view of the shared context, including installed modules, app structure, routes, and conventions. Paste that output into the external assistant so it can use what your app already has instead of inventing new patterns.
## List of Modules
-*We update this list regularly. If you have a module you'd like to see here, feel free to [open an issue](https://github.com/leafsphp/docs/issues/new) or create a pull request on our documentation repository. Community created modules are welcome here too β€οΈ*
+*We update this list regularly. If you have a module you'd like to see here, feel free to [open an issue](https://github.com/leafsphp/docs/issues/new) or create a pull request on our documentation repository. Community-created modules are welcome too.*
| Project | Status | Description |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
-| [alchemy](/docs/utils/testing) | [](https://packagist.org/packages/leafs/alchemy) [](https://packagist.org/packages/leafs/alchemy) | Setup testing/linting for your PHP apps |
-| [aloe](/docs/mvc/console) | [](https://packagist.org/packages/leafs/aloe) [](https://packagist.org/packages/leafs/aloe) | Smart console helper for Leaf MVC |
+| [alchemy](/docs/utils/testing) | [](https://packagist.org/packages/leafs/alchemy) [](https://packagist.org/packages/leafs/alchemy) | Tests, code style, refactoring, static analysis + CI for any PHP app, from one config |
| [anchor](/docs/security/anchor) | [](https://packagist.org/packages/leafs/anchor) [](https://packagist.org/packages/leafs/anchor) | Built-in protection for your Leaf apps |
| [auth](/docs/auth/) | [](https://packagist.org/packages/leafs/auth) [](https://packagist.org/packages/leafs/auth) | Simple but powerful authentication system for your apps |
| [bareui](/docs/frontend/bareui) | [](https://packagist.org/packages/leafs/bareui) [](https://packagist.org/packages/leafs/bareui) | Dead simple templating engine with no compilation |
+| [billing](/docs/utils/billing) | [](https://packagist.org/packages/leafs/billing) [](https://packagist.org/packages/leafs/billing) | Subscriptions and one-time payments, one API across providers |
| [blade](/docs/frontend/blade) | [](https://packagist.org/packages/leafs/blade) [](https://packagist.org/packages/leafs/blade) | Laravel blade port for leaf |
+| [cache](/docs/utils/cache) | [](https://packagist.org/packages/leafs/cache) [](https://packagist.org/packages/leafs/cache) | Cache results of expensive operations |
| [cookie](/docs/http/cookies) | [](https://packagist.org/packages/leafs/cookie) [](https://packagist.org/packages/leafs/cookie) | Cookie management for your PHP apps |
| [cors](/docs/http/cors) | [](https://packagist.org/packages/leafs/cors) [](https://packagist.org/packages/leafs/cors) | CORS operations made simple |
| [csrf](/docs/security/csrf) | [](https://packagist.org/packages/leafs/csrf) [](https://packagist.org/packages/leafs/csrf) | CSRF protection for your Leaf apps |
| [date](/docs/utils/date) | [](https://packagist.org/packages/leafs/date) [](https://packagist.org/packages/leafs/date) | Dead simple PHP dates |
| [db](/docs/database/) | [](https://packagist.org/packages/leafs/db) [](https://packagist.org/packages/leafs/db) | Lightweight query builder for your PHP apps |
-| [devtools](/docs/routing/error-handling) | [](https://packagist.org/packages/leafs/devtools) [](https://packagist.org/packages/leafs/devtools) | Developer tools for Leaf PHP |
-| [eien](/docs/swoole) | [](https://packagist.org/packages/leafs/eien) [](https://packagist.org/packages/leafs/eien) | High-speed, high-performance server for leaf |
-| [exception](https://github.com/leafsphp/exceptions) | [](https://packagist.org/packages/leafs/exception) [](https://packagist.org/packages/leafs/exception) | Leaf's exception wrapper (fork of whoops) |
+| [exception](/docs/routing/error-handling) | [](https://packagist.org/packages/leafs/exception) [](https://packagist.org/packages/leafs/exception) | Crash reports with stack traces, user journeys and AI handoff |
| [fetch](/docs/utils/fetch) | [](https://packagist.org/packages/leafs/fetch) [](https://packagist.org/packages/leafs/fetch) | HTTP requests made simple |
| [form](/docs/data/validation) | [](https://packagist.org/packages/leafs/form) [](https://packagist.org/packages/leafs/form) | Form processes and validation |
| [fs](/docs/utils/fs) | [](https://packagist.org/packages/leafs/fs) [](https://packagist.org/packages/leafs/fs) | Awesome filesystem operations + file uploads |
| [http](/docs/http/request) | [](https://packagist.org/packages/leafs/http) [](https://packagist.org/packages/leafs/http) | Http operations made simple (request, response, ...) |
| [inertia](/docs/frontend/inertia) | [](https://packagist.org/packages/leafs/inertia) [](https://packagist.org/packages/leafs/inertia) | Leaf adapter for inertia JS |
+| [lingo](/docs/utils/lingo) | [](https://packagist.org/packages/leafs/lingo) [](https://packagist.org/packages/leafs/lingo) | Multi-language (i18n) support for your apps |
| [logger](/docs/routing/error-handling) | [](https://packagist.org/packages/leafs/logger) [](https://packagist.org/packages/leafs/logger) | leaf logger module |
| [mail](/docs/utils/mail/) | [](https://packagist.org/packages/leafs/mail) [](https://packagist.org/packages/leafs/mail) | Mailing made easy with leaf |
| [mvc-core](/docs/mvc/) | [](https://packagist.org/packages/leafs/mvc-core) [](https://packagist.org/packages/leafs/mvc-core) | Brain of Leaf MVC |
| [password](/docs/data/encryption) | [](https://packagist.org/packages/leafs/password) [](https://packagist.org/packages/leafs/password) | Password encryption/validation/hashing in one box |
+| [paystack](/docs/utils/billing) | [](https://packagist.org/packages/leafs/paystack) [](https://packagist.org/packages/leafs/paystack) | Billing with paystack |
+| [queue](/docs/utils/queues) | [](https://packagist.org/packages/leafs/queue) [](https://packagist.org/packages/leafs/queue) | Queue integration for leaf |
| [redis](/docs/database/redis) | [](https://packagist.org/packages/leafs/redis) [](https://packagist.org/packages/leafs/redis) | Functionality for Redis |
-| [router](/docs/routing/) | [](https://packagist.org/packages/leafs/router) [](https://packagist.org/packages/leafs/router) | Leaf Router copy for use outside of Leaf |
+| [s3](/docs/utils/fs#using-s3-or-other-cloud-storage-services) | [](https://packagist.org/packages/leafs/s3) [](https://packagist.org/packages/leafs/s3) | Drop-in aws s3 module for Leaf FS |
+| [schema](https://github.com/leafsphp/schema) | [](https://packagist.org/packages/leafs/schema) [](https://packagist.org/packages/leafs/schema) | Git for your database: migrations, seeds and schema files |
+| [seedling](/docs/seedling/) | [](https://packagist.org/packages/leafs/seedling) [](https://packagist.org/packages/leafs/seedling) | Lightweight console application framework |
| [session](/docs/http/session) | [](https://packagist.org/packages/leafs/session) [](https://packagist.org/packages/leafs/session) | PHP sessions made simple |
+| [sitemap](/docs/utils/sitemaps) | [](https://packagist.org/packages/leafs/sitemap) [](https://packagist.org/packages/leafs/sitemap) | PHP sitemaps made simple |
+| [sprout](/docs/mvc/commands) | [](https://packagist.org/packages/leafs/sprout) [](https://packagist.org/packages/leafs/sprout) | Fast, lightweight and minimal CLI framework for PHP |
+| [stripe](/docs/utils/billing) | [](https://packagist.org/packages/leafs/stripe) [](https://packagist.org/packages/leafs/stripe) | Billing with Stripe |
| [vite](/docs/frontend/vite) | [](https://packagist.org/packages/leafs/vite) [](https://packagist.org/packages/leafs/vite) | Leaf server component for Vite |
-
-
-
## Community Modules
-This is a list of modules created by the Leaf community. These modules are not officially maintained by the Leaf team, but they are welcome here. If you have a module you'd like to see here, feel free to [open an issue](https://github.com/leafsphp/docs/issues/new) or create a pull request on our documentation repository.
+Modules created by the Leaf community are welcome here. They're not officially maintained by the Leaf team, but we're happy to list them. Built something? [Open an issue](https://github.com/leafsphp/docs/issues/new) or send a pull request on the documentation repository to add yours.
-| Project | Status | Description |
-| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
-| [devcycle/devcycle-leaf-plugin](https://github.com/DevCycleHQ-Sandbox/devcycle-leaf-plugin) | [](https://packagist.org/packages/devcycle/devcycle-leaf-plugin) [](https://packagist.org/packages/devcycle/devcycle-leaf-plugin) | Devcycle Leaf Module |
diff --git a/src/docs/mvc/commands.md b/src/docs/mvc/commands.md
index a0556c9c..a7695d58 100644
--- a/src/docs/mvc/commands.md
+++ b/src/docs/mvc/commands.md
@@ -1,10 +1,10 @@
# Writing Commands
-Commands let you automate repetitive tasksβwhether it's spinning up a project, running tests, or deploying your app. You can wrap all that logic into reusable commands that you or your team run in a heartbeat.
+Commands let you automate repetitive tasks like spinning up a project, running tests, or deploying your app. You can wrap all that logic into reusable commands that you or your team run in a heartbeat.
::: details Choosing Seedling over Leaf MVC
-If you're focused on building command-line tools rather than full web apps, consider using [Leaf's Seedling](/docs/seedling/) over Leaf MVC. Seedling offers a lightweight, optimized environment solely for CLI workflows β no HTTP or view layers required. It's perfect when your project is all about commands and utilities.
+If you're focused on building command-line tools rather than full web apps, consider using [Leaf's Seedling](/docs/seedling/) over Leaf MVC. Seedling offers a lightweight environment built solely for CLI workflows, with no HTTP or view layers required. It's perfect when your project is all about commands and utilities.
:::
@@ -31,7 +31,7 @@ class CachePurgeCommand extends Command
{
protected $signature = 'cache:purge
{argument? : argument description}
- {--o|option? : option description}';
+ {--o|option= : option description}';
protected $description = 'cache:purge command\'s description';
protected $help = 'cache:purge command\'s help';
@@ -123,16 +123,16 @@ Command options are also known as flags or switches, and they are additional par
leaf example --option1 --option2 valueForOption2 -o valueForOption3
```
-To add an option to your command, you need to add it to the `protected $signature` property of your command class. You can define whether the option is required or optional by adding a `?` at the end of the option name. You can also define a shortcut for the option by adding it before the option name, separated by a `|`.
+To add an option to your command, you need to add it to the `protected $signature` property of your command class. Options are always optional; what changes is whether they act as a simple on/off switch or expect a value. You can also define a shortcut for an option by adding it before the option name, separated by a `|`.
```php
protected $signature = 'example
- {--option1 : option1 description}
- {--option2? : option2 description}
- {--o|option3? : option3 description}';
+ {--option1 : a switch, false unless passed}
+ {--option2= : an option that expects a value}
+ {--o|option3=leaf : an option with a shortcut and a default value}';
```
-In this case, `option1` is required, `option2` is optional, and `option3` is optional with a shortcut of `o`. A user could run the command like this:
+In this case, `option1` is a boolean switch (`option('option1')` returns `false` unless the user passes `--option1`), `option2` expects a value, and `option3` expects a value, defaults to `leaf`, and has a shortcut of `o`. A user could run the command like this:
```bash:no-line-numbers
leaf example --option1 --option2 valueForOption2 -o valueForOption3
@@ -356,9 +356,7 @@ $answers = sprout()->prompt([
'message' => 'What is your name?',
],
[
- 'type' => function ($answers) {
- return strtolower($answers['username']) === 'admin' ? null : 'confirm';
- },
+ 'type' => fn ($answers) => strtolower($answers['username']) === 'admin' ? null : 'confirm',
'name' => 'userConfirm',
'message' => 'Are you above 18?',
],
diff --git a/src/docs/mvc/console.md b/src/docs/mvc/console.md
index 226843f8..3c7aa013 100644
--- a/src/docs/mvc/console.md
+++ b/src/docs/mvc/console.md
@@ -2,315 +2,151 @@
-Leaf MVC includes a powerful command-line tool called Aloe to help you manage your application from the terminal. With Aloe, you can scaffold projects, manage databases, and handle various app tasks efficiently, all with simple commands. To get started and see the list of all available commands, just run:
+Leaf MVC ships with a built-in console for managing your application from the terminal: generators, scaffolds, database commands, and app utilities, all through the `leaf` file in your project root. It's powered by [Sprout](https://seedling.leafphp.dev), Leaf's console engine, and there's nothing to install: every Leaf MVC app has it from the first `leaf create`.
+
+To see every command available in your app, run:
```bash:no-line-numbers
php leaf list
```
-::: details Missing commands?
-
-If you get errors from commands which you saw in the documentation, you are probably running an older version of the Leaf MVC console. We add more handy commands regularly, but as the console does not automatically update, you may run into the missing command error. To fix that problem, you need to install the latest version of Aloe:
-
-::: code-group
-
-```bash:no-line-numbers [Leaf CLI]
-leaf install aloe@v4.0-beta
-leaf install mvc-core@v4.0-beta
-```
-
-```bash:no-line-numbers [Composer]
-composer require leafs/aloe:v4.0-beta
-composer require leafs/mvc-core:v4.0-beta
-```
-
+::: info Coming from Leaf 4?
+Earlier Leaf MVC versions used a separate console package called Aloe. Aloe is retired in Leaf 5. The console is now part of MVC core itself, so there's no extra dependency to install or update. Your muscle memory survives: the command names (`g:controller`, `db:migrate`, `scaffold:auth`, β¦) are the same.
:::
-## Aloe vs. Leaf CLI: What's the Difference?
-
-Before diving in, itβs important to know that Aloe is different from Leaf CLI.
-
-Leaf CLI is a general tool used for creating and managing any Leaf application. It's installed globally and works across different Leaf apps, including Leaf MVC.
-
-Aloe is more specific. It's used only in the root directory of your Leaf MVC apps. Aloe has commands that are specifically designed for managing Leaf MVC projects.
-
-## Aloe Command Categories
-
-Aloe commands are divided into six groups to help with different parts of your development process:
-
-- App Commands: Manage your app's state and dependencies.
-- Scaffold Commands: Create files and structures in your app.
-- Generate Commands: Quickly create controllers, models, and more.
-- Delete Commands: Remove unwanted files.
-- Database Commands: Manage your appβs database.
-- View Commands: Build and serve your frontend.
-
-### App Commands
-
-- Serve
-
- To run your app, use the serve command, which starts a development server. Itβs similar to running php -S localhost:[PORT], but with some added setup specific to Leaf.
-
- ```bash:no-line-numbers
- leaf serve
- ```
-
- You can also specify a custom port:
-
- ```bash:no-line-numbers
- leaf serve --port=8000
- ```
-
-- Interact
-
- If you want to interact with your app directly in the terminal, use interact. This opens a REPL (Read-Eval-Print Loop) powered by PsySH.
-
- ```bash:no-line-numbers
- php leaf interact
- ```
-
-- Maintenance Mode
-
- Sometimes you need to take your app down for maintenance. Use app:down to put your app in maintenance mode (it will return a 503 status), and app:up to bring it back online.
-
- ```bash:no-line-numbers
- leaf app:down
- leaf app:up
- ```
-
-### Scaffold Commands
-
-These commands help you quickly create files and structure your app.
-
-- Scaffold Authentication
-
- Need basic user authentication? Use the auth:scaffold command to automatically generate everything you need for login and registration (routes, models, controllers, views, etc.).
-
- ```bash:no-line-numbers
- leaf auth:scaffold
- ```
-
- For a Leaf MVC app: generates full login and registration views and controllers. You can force it to generate API files using `--api`.
-
- ```bash:no-line-numbers
- leaf auth:scaffold --api
- ```
-
-- Mail Setup
-
- To set up mailing for your app, run:
-
- ```bash:no-line-numbers
- leaf mail:setup
- ```
+## The console vs. Leaf CLI
- This installs the Leaf Mail package and sets up the necessary configuration files.
+Two different tools, two different jobs:
-### Generate Commands
+- **Leaf CLI** (`leaf` installed globally) creates and manages projects from anywhere: `leaf create`, `leaf install`, `leaf serve`.
+- **The MVC console** (`php leaf` inside a project) manages *this* app: generating files, running migrations, scaffolding features.
-These commands are used to generate files for your project, saving you time by automating tasks like creating controllers, models, schema files, etc.
+Inside a Leaf MVC project, the global CLI hands commands it doesn't know over to your app's console, so `leaf g:controller Posts` and `php leaf g:controller Posts` do the same thing.
-- Create a Controller
+## Generators
- To generate a new controller, use:
-
- ```bash:no-line-numbers
- leaf g:controller [name]
- ```
-
- You can add a resource route (for standard CRUD operations) with:
-
- ```bash:no-line-numbers
- leaf g:controller [name] --resource
- ```
-
- You can also create a controller with a model or schema file:
-
- ```bash:no-line-numbers
- leaf g:controller [name] --model
- leaf g:controller [name] --all # or -a to generate everything
- ```
-
-- Create a Model
-
- Need a model for your database? Generate one with:
-
- ```bash:no-line-numbers
- leaf g:model [name]
- ```
-
-
-
-- Other Generate Commands
-
- - Factory: leaf g:factory [name]
- - Helper: leaf g:helper [name]
- - Mailer: leaf g:mailer [name]
- - Schema file: leaf g:schema [name]
- - Seed: leaf g:seed [name]
- - View Template: leaf g:template [name] --type=[blade|jsx|vue|html]
-
-### Delete Commands
-
-These are the reverse of generate commandsβuse them to delete files.
-
-- Delete Controller: leaf d:controller [name]
-- Delete Model: leaf d:model [name]
-- Delete Schema: leaf d:schema [name]
-- Delete Seed: leaf d:seed [name]
-
-### Database Commands
-
-Leaf MVC makes database management easy with these commands.
-
-- Create a Database
-
- To create a new database from the credentials in your .env file, use:
-
- ```bash:no-line-numbers
- leaf db:install
- ```
-
-- Migrate Database
-
- To migrate your db using your schema files, run:
-
- ```bash:no-line-numbers
- leaf db:migrate
- ```
+php leaf g:controller Posts # controller
+php leaf g:controller Posts -m # controller + model
+php leaf g:controller Posts -a # controller + model + schema
+php leaf g:controller Posts --resource # full CRUD controller
+php leaf g:model Post # model
+php leaf g:schema posts # schema YAML file
+php leaf g:middleware LogRequest # middleware class
+php leaf g:mailer Welcome # mailer class
+php leaf g:job SendEmail # queue job
+php leaf g:route posts # route partial in app/routes
+php leaf g:template home # view file
+php leaf g:helper Format # helper class
+```
-- Reset Database
+Some generators come with the modules that power them: `g:model` and `g:schema` arrive with the db/schema module, `g:job` with the queue module. They register themselves automatically when the module is installed, and `php leaf list` always shows what your app can do right now.
- This command rolls back, migrates, and seeds your database in one go:
+Made a mess? Every generator has a matching delete command: `d:controller`, `d:model`, `d:schema`, `d:job`.
- ```bash:no-line-numbers
- leaf db:reset
- ```
+## Scaffolds
- You can skip the seeding step if you want:
+Scaffolds generate complete features, not single files:
- ```bash:no-line-numbers
- leaf db:reset --noSeed
- ```
+```bash:no-line-numbers
+php leaf scaffold:auth # full authentication: signup, login, protected routes
+php leaf scaffold:landing-page # landing page for your app
+php leaf scaffold:waitlist # waitlist capture
+php leaf scaffold:mail # leaf mail + config
+php leaf scaffold:shadcn # shadcn/ui for your React frontend
+php leaf scaffold:ai # streaming AI chat powered by Claude
+php leaf scaffold:blog # markdown blog with a sample post
+php leaf scaffold:contact # contact form wired to leaf mail
+php leaf scaffold:legal # editable privacy + terms pages
+php leaf scaffold:subscriptions # billing/subscriptions (needs leaf billing)
+```
-- Rollback Database
+## Database
- If you need to undo recent changes, you can roll back your migrations with:
+Your schema lives in YAML files under `app/database/` (one per table), and these commands move it into your database:
- ```bash:no-line-numbers
- leaf db:rollback
- ```
+```bash:no-line-numbers
+php leaf db:migrate # apply your schema files
+php leaf db:seed # seed the database with records
+php leaf db:rollback # roll back to a previous state
+php leaf db:reset # reset migration history + tables
+php leaf db:drop # drop tables and reset migration history
+```
- You can also rollback a specific number of migrations using the --step flag:
+See [Database](/docs/database/) for how schema files work.
- ```bash:no-line-numbers
- leaf db:rollback --step=2
- ```
+## App utilities
-- Seed Database
+```bash:no-line-numbers
+php leaf serve # start the development server
+php leaf app:down # put the app in maintenance mode
+php leaf app:up # bring it back
+php leaf env:generate # generate a .env file
+php leaf env:set KEY=val # set an environment variable
+php leaf key:generate # generate/regenerate your app key
+php leaf link # symlink the storage directory
+php leaf config:publish # publish config files to your project
+php leaf interact # interact with your app in a REPL-style session
+```
- To populate your database with dummy data, use:
+## Frontend
- ```bash:no-line-numbers
- leaf db:seed
- ```
+When your app has a frontend setup, the console proxies your asset tooling so you never leave one terminal:
-### View Commands
+```bash:no-line-numbers
+php leaf view:install # install frontend scaffolding
+php leaf view:dev # run your frontend dev command
+php leaf view:build # run your frontend build command
+```
-These commands handle your frontend setup, building, and serving.
+## Queues
-- Build Your Frontend
+With the queue module installed:
- When youβre ready to compile your frontend for production, run:
+```bash:no-line-numbers
+php leaf queue:work # start your queue worker
+```
- ```bash:no-line-numbers
- leaf view:build
- ```
+## Writing your own commands
-- Serve Your Frontend
+Your app's own commands live in `app/console/` (autoloaded under `App\Console`). A command is a small Sprout class (a `signature`, a `description`, and a `handle()` method) and it appears in `php leaf list` alongside the built-ins:
- To start your frontend development server, use:
+```php
+argument('team') ?? 'everyone';
- This will display all available commands for your version of Leaf MVC.
+ if ($this->option('dry-run')) {
+ $this->info("Would send reports for: $team");
- With this guide, you should be ready to take full advantage of Aloe and streamline your Leaf MVC app development. Happy coding! π
+ return 0;
+ }
-## Command List
+ // your app's models, helpers and lib/ functions are all available here
+ $this->info("Reports sent for: $team");
-This is a list of every command available in Aloe. To view this list from your terminal, run `leaf list`.
+ return 0;
+ }
+}
+```
```bash:no-line-numbers
-Leaf MVC v4.x-BETA
-
-Usage:
- command [options] [arguments]
-
-Options:
- -h, --help Display help for the given command. When no command is given display help for the list command
- -q, --quiet Do not output any message
- -V, --version Display this application version
- --ansi|--no-ansi Force (or disable --no-ansi) ANSI output
- -n, --no-interaction Do not ask any interactive question
- -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
-
-Available commands:
- completion Dump the shell completion script
- help Display help for a command
- interact Interact with your application
- link Create a symbolic link for the storage directory
- list List commands
- serve Start the leaf development server
- app
- app:down Place app in maintainance mode
- app:up Remove app from maintainance mode
- config
- config:lib Setup Leaf MVC to use external libraries
- config:publish Publish config files to your project
- d
- d:command Delete a console command
- d:controller Delete a controller
- d:model Delete a model
- db
- db:migrate Migrate your db schema files
- db:reset Reset migration history + db tables
- db:rollback Rollback database to a previous state
- db:seed Seed the database with records
- devtools
- devtools:install Install the Leaf PHP devtools
- env
- env:generate Generate .env file
- g
- g:command Create a new console command
- g:controller Create a new controller class
- g:helper Create a new helper class
- g:mailer Create a new mailer
- g:middleware Create a new application middleware
- g:model Create a new model class
- g:schema Create a new schema file
- g:template [g:view] Create a new view file
- key
- key:generate Generate/Regenerate your app key
- scaffold
- scaffold:auth Scaffold basic app authentication
- scaffold:mail Install leaf mail and setup mail config
- view
- view:build Run your frontend dev server
- view:dev [view:serve] Run your frontend dev server
- view:install Run a script in your composer.json
+php leaf send-weekly-reports design --dry-run
```
+
+Command names take dashes and namespaces freely (`send-weekly-reports`, `reports:send-weekly`), and everything your app loads (models, helpers, `lib/` functions) is available inside `handle()`. See the [Seedling docs](https://seedling.leafphp.dev) for the full command-writing guide.
diff --git a/src/docs/mvc/controllers.md b/src/docs/mvc/controllers.md
index 72859567..b3677760 100644
--- a/src/docs/mvc/controllers.md
+++ b/src/docs/mvc/controllers.md
@@ -5,7 +5,9 @@ prev: false
# Controllers
-When building a web app with Leaf, you need to define routesβthese are the paths that users visit in your app. For example, `/login` or `/signup`. Normally, you can just tell Leaf what to do when someone visits a route by passing it a function (a piece of code that runs when someone visits the route). This works fine if your app is small. Here's an example:
+When building a web app with Leaf, you need to define routes: the paths that users visit in your app. For example, `/login` or `/signup`.
+
+Normally, you can just tell Leaf what to do when someone visits a route by passing it a function (a piece of code that runs when someone visits the route). This works fine if your app is small. Here's an example:
```php
app()->get('/login', function () {
@@ -17,7 +19,9 @@ This is okay for simple apps, but as your app grows, it can get messy. You don
## What are controllers?
-Controllers are classes that contain methods (functions) that handle requests to your app. When a request comes into your app, Leaf calls the method in the controller that matches the route. This keeps your route definitions clean, and let's you neatly organize your logic so you don't mix your application logic with any other code. Leaf MVC includes a really handy command that you can use to create controllers:
+Controllers are classes that contain methods (functions) that handle requests to your app. When a request comes into your app, Leaf calls the method in the controller that matches the route. This keeps your route definitions clean, and let's you neatly organize your logic so you don't mix your application logic with any other code.
+
+Leaf MVC includes a really handy command that you can use to create controllers:
```bash
leaf g:controller
@@ -38,7 +42,7 @@ class UsersController extends Controller
{
public function index()
{
- response()->json([
+ return response()->json([
'message' => 'UsersController@index output'
]);
}
@@ -68,7 +72,7 @@ Notice that we didn't pass a function to the route definition. Instead, we passe
## Why Use Controllers?
- Organization: Keeps your route definitions and logic separate, making your code easier to understand.
-- Scalability: As your app grows, you wonβt have one big file with all your logicβit will be split up into small, manageable pieces.
+- Scalability: As your app grows, you wonβt have one big file with all your logic; it will be split up into small, manageable pieces.
- Reusability: You can reuse controller methods for multiple routes if needed.
## Outputting Views
@@ -78,7 +82,7 @@ In fullstack applications, you'll need to render views (HTML pages) to the user.
```php
public function index()
{
- response()->render('users');
+ return response()->render('users');
}
```
@@ -86,7 +90,9 @@ You can find the views documentation [here](/docs/frontend/)
## Route Parameters
-When you're building web apps, sometimes you need extra functionality when someone visits a route. For example, maybe only logged-in users should be able to see certain pages. To manage this, Leaf lets you add route parameters like middleware to your routes. This feature also works for controllers and uses the same syntax as function route handlers. Here's an example:
+When you're building web apps, sometimes you need extra functionality when someone visits a route. For example, maybe only logged-in users should be able to see certain pages.
+
+To manage this, Leaf lets you add route parameters like middleware to your routes. This feature also works for controllers and uses the same syntax as function route handlers. Here's an example:
```php:no-line-numbers
app()->get('/users', ['middleware' => 'auth', 'UsersController@index']);
@@ -98,7 +104,7 @@ In the example above, we passed in a middleware called `auth` to the route as a
Leaf makes it super easy to set up routes for common actions like creating, reading, updating, and deleting data (also known as CRUD operations). Instead of manually setting up each route, you can use resource controllers to do it all in one line of code!
-To get started, you can generate a resource controller using the Aloe CLI:
+To get started, you can generate a resource controller using the MVC console:
```bash:no-line-numbers
leaf g:controller photos --resource
@@ -169,7 +175,7 @@ This will automatically set up all the routes you need for CRUD operations on th
## API Resource Controllers
-API resource controllers are similar to resource controllers, but they return JSON responses instead of HTML which means that the `create` and `edit` methods are not included. You can generate an API resource controller using the Aloe CLI:
+API resource controllers are similar to resource controllers, but they return JSON responses instead of HTML which means that the `create` and `edit` methods are not included. You can generate an API resource controller using the MVC console:
```bash:no-line-numbers
leaf g:controller photos --api
diff --git a/src/docs/mvc/globals.md b/src/docs/mvc/globals.md
index cf8ee584..7f1682f9 100644
--- a/src/docs/mvc/globals.md
+++ b/src/docs/mvc/globals.md
@@ -4,7 +4,7 @@ Leaf MVC comes with a couple of global functions that you can use to access your
## Loading app paths
-Since Leaf MVC comes with a robust structure out of the box, it also comes with quick ways to reference files in these structures. For example, if you want to reference a file in your `public` folder, you can use the `PublicPath()` helper.
+Since Leaf MVC comes with a defined folder structure out of the box, it also comes with quick ways to reference files in these structures. For example, if you want to reference a file in your `public` folder, you can use the `PublicPath()` helper.
### AppPaths()
diff --git a/src/docs/mvc/index.md b/src/docs/mvc/index.md
index ad1a7c8e..dda11056 100644
--- a/src/docs/mvc/index.md
+++ b/src/docs/mvc/index.md
@@ -1,6 +1,8 @@
---
next: false
prev: false
+title: 'PHP MVC Framework: clean structure without the weight'
+description: Leaf MVC is a lightweight PHP MVC framework with controllers, models, views and a full CLI.
---
# Leaf + MVC
@@ -8,188 +10,69 @@ prev: false
-Leaf is a lightweight PHP framework with a ton of loosely coupled libraries that can be used to build any kind of application. By default, Leaf doesn't give you a lot of structure, but it fully supports the MVC pattern without any extra configuration.
+
## What is MVC?
-MVC stands for Model-View-Controller. It is a pattern that separates your application into three distinct parts:
+MVC stands for Model-View-Controller. It separates your application into the parts that hold data, display interfaces, and respond to requests.
-- Models: These are the classes that represent your data. They are responsible for interacting with your database, and for validating your data.
-- Views: These are the files that are responsible for displaying your data to your user. They are usually written in HTML, but can also be written in other templating languages like [BareUI](/docs/frontend/bareui) or [Blade](/docs/frontend/blade) or frameworks like [Vue](https://vuejs.org/) or [React](https://reactjs.org/)
-- Controllers: These are the classes that are responsible for handling the user's request, and for returning the appropriate response.
+
::: details New to MVC?
-If you're new to the MVC pattern, you can take a look at this video by Traversy Media that explains the MVC pattern, how it works and how it works in real-world applications.
+MVC is a simple way to keep application code organized. Models talk to data, views present the interface, and controllers coordinate requests. Traversy Media has a useful overview if you want a broader introduction before building with Leaf.
-
+[Watch the MVC overview](https://www.youtube.com/watch?v=pCvZtjoRq1I)
:::
## MVC in Leaf
-Leaf MVC is a minimal yet powerful setup for building applications with the MVC pattern. It extends Leaf with additional tools and structure, making development faster and more intuitive. With a clean, organized codebase, Leaf MVC is a great starting point for building scalable and maintainable applications.
+Leaf MVC is a minimal setup for building structured applications. It adds the folders and commands most projects need, but avoids forcing your app into a heavy framework model.
-
-
-
+
## Directory Structure
-Leaf MVCβs directory structure is inspired by [Rails](https://rubyonrails.org/) and [Laravel](https://laravel.com/) but remains lightweight and flexible. Itβs a solid starting point, fully equipped with everything you need to build a modern web application.
-
-A fresh Leaf MVC app follows this structure:
-
-::: code-group
-
-```bash:no-line-numbers [Default Starter]
-ββββapp
-β βββ controllers
-β βββ database
-β βββ models
-β βββ routes
-β βββ views
-ββββpublic
- ββββassets
- βββ css
- βββ img
-```
-
-```bash:no-line-numbers [API Starter]
-ββββapp
-β βββ controllers
-β βββ database
-β βββ models
-β βββ routes
-ββββpublic
-```
+Leaf MVC's directory structure is inspired by Rails and Laravel, but it stays lightweight and flexible. A fresh app starts with the places most product code naturally belongs.
-:::
+
-- app/ β This is where all your application logic lives, including controllers, models, views, and routes. Your database files also reside here.
-- public/ β Contains publicly accessible files like bundled CSS, JavaScript, and images. This is the only directory exposed to the browser.
-
-There are also some folders that may be generated automatically by modules like the `storage` directory, which is used to store logs, cache, and other temporary files.
+Modules may also generate folders like `storage` for logs, cache, and temporary files.
## Configuring Leaf MVC
-Leaf MVC works out of the box with minimal setupβmost apps just need a few tweaks in the .env file, so it doesnβt include a config directory by default. When customization is needed, config files are organized by feature, making it easy to adjust settings without affecting others. To publish all default config files, run the following command:
-
-```bash:no-line-numbers
-leaf config:publish
-```
-
-This command will create the `config` directory in your app and copy all default config files, just like in earlier versions. You can also publish a specific config file while keeping the rest untouched:
-
-```bash:no-line-numbers
-leaf config:publish
-```
-
-Here is a list of all available Leaf MVC config files:
+Leaf MVC works out of the box. Most projects only need a few environment variables, so there is no config directory until you publish one.
-| Config file | Use-case |
-| ----------------- | :------------------------------------------------------------ |
-| app | Configuration for core features |
-| auth | Configuration for authentication (requires auth module) |
-| cors | Configuration for cors (requires cors module) |
-| csrf | Configuration for csrf protection (requires csrf module) |
-| database | Configuration for database stuff |
-| mail | Configuration for mailing (requires mail module) |
-| redis | Configuration for redis management (requires redis module) |
-| queue | Configuration for queue management (requires queue module) |
-| view | Configuration for view rendering |
+
## Application Environment
-Leaf MVC includes a `.env.example` file, which is copied to `.env` during installation. This file stores environment variables like database credentials, making it easy to configure different environments (development, testing, production). All values in `.env` are automatically loaded into the application, and you can access them using the `_env()` helper function. This function takes a key and an optional default value if the variable isn't set. Here's an example:
+Leaf MVC ships with a `.env.example` file that is copied to `.env` during installation. Values are automatically loaded and available through the `_env()` helper.
```php
$database = _env('DB_DATABASE');
$databaseWithDefault = _env('DB_DATABASE', 'leaf');
```
-Be careful not to commit your `.env` file to your version control system as it contains sensitive information. We have already added the `.env` file to your `.gitignore` file so you don't have to worry about this.
+Do not commit your `.env` file. Leaf MVC already adds it to `.gitignore` because it can contain database credentials, API keys, and other secrets.
## Building with Leaf MVC
-Although Leaf MVC is structured, it is still incredibly flexible, and offers you different ways to build your application. You can build a full-stack application using your favourite frontend tooling, or an extensive API using all the tools Leaf MVC provides. We have guides on how to build different types of applications with Leaf MVC, so you can choose the one that best fits your use-case.
-
-
-
-
-
-
-
- MVC for Full-stack
-
-
- Build full-stack applications with Leaf MVC.
-
-
-
-
-
-
-
-
-
-
-
-
-
- MVC for APIs
-
-
- Build APIs with a structured approach for better organization.
-
-
-
-
-
-
-
-
+Leaf MVC gives you structure without taking away your choices. Build a full-stack app, serve a frontend with Inertia or Blade, or expose a clean JSON API for any client.
+
+
diff --git a/src/docs/mvc/libraries.md b/src/docs/mvc/libraries.md
index 04390239..8a13dcd5 100644
--- a/src/docs/mvc/libraries.md
+++ b/src/docs/mvc/libraries.md
@@ -2,7 +2,7 @@
We usually recommend abstracting repetitive code into helpers, but sometimes you need logic that doesnβt quite fit into a controller, model, or helper. Thatβs where custom libraries come in.
-Say you need a function to calculate the distance between two points on a map. Instead of scattering this logic across your app, you can create a reusable library and use it anywhereβin controllers, helpers, or views.
+Say you need a function to calculate the distance between two points on a map. Instead of scattering this logic across your app, you can create a reusable library and use it anywhere: in controllers, helpers, or views.
Custom libraries arenβt stored in the `app` folder because Leaf MVC doesnβt autoload them by default. Instead, store them in the `lib` folder, and Leaf will pick them up. This allows flexibility, especially when working with libraries that donβt follow an autoloadable structure and need to be required manually.
@@ -35,7 +35,7 @@ To create a library, simply create a new file in the `lib` folder. For example,
namespace MyRandom\Name\Space;
class Math {
- public static function add($a, $b) {
+ public static function add(int $a, int $b): int {
return $a + $b;
}
}
@@ -69,7 +69,7 @@ As mentioned above, libraries can be just about anything. They are completely ba
namespace Lib;
-function add($a, $b) {
+function add(int $a, int $b): int {
return $a + $b;
}
```
@@ -93,7 +93,9 @@ class HomeController extends Controller {
## Using a non-autoloadable library
-Some older libraries may not follow the autoloadable structure but are linked together using `require` statements. You can still use these libraries in your Leaf MVC application. To use such a library, you need to add it's index file to the `lib` folder and require any other files it needs in the index file. For example, let's say you have a library called `MyLibrary` that has the following structure:
+Some older libraries may not follow the autoloadable structure but are linked together using `require` statements. You can still use these libraries in your Leaf MVC application.
+
+To use such a library, you need to add it's index file to the `lib` folder and require any other files it needs in the index file. For example, let's say you have a library called `MyLibrary` that has the following structure:
```bash:no-line-numbers
MyLibrary/
diff --git a/src/docs/mvc/scaffolds.md b/src/docs/mvc/scaffolds.md
index 036b83f0..10a9e873 100644
--- a/src/docs/mvc/scaffolds.md
+++ b/src/docs/mvc/scaffolds.md
@@ -1,34 +1,114 @@
# Application Scaffolding Leaf MVC Only
-Leaf 4 is all about how quickly you can go from idea to a working application, and scaffolding is a big part of that. Leaf MVC comes with a powerful console tool that allows you to scaffold entire features in your application with a single command.
+
-## Authentication
+
+
+
+
Feature scaffolds
+
Generate the boring parts, then make the product yours.
+
Leaf MVC scaffolding creates complete feature starting points: routes, controllers, models, schema files, views, middleware, and frontend pieces that match your app setup.
+
+
+
+
+
+
+
+ scaffold
+
+
+
$ leaf scaffold:auth
+
$ leaf scaffold:landing-page
+
$ leaf scaffold:subscriptions
+
$ leaf scaffold:waitlist
+
$ leaf scaffold:blog
+
$ leaf scaffold:ai
+
+
+
+
+
What scaffolds give you
+
+
+
A real starting point
+
Not snippets. A working feature shape you can run, edit, and extend.
+
+
+
Your frontend setup
+
Views and components are generated around the frontend tooling your app uses.
+
+
+
AI-ready commands
+
Assistants can run the same commands and then tailor the result to your product.
+
+
+
+
+
+
+## How scaffolding works
+
+Scaffolds are meant to remove repetitive setup, not hide your code. You run a command, Leaf creates the feature files, and you keep full ownership of what was generated.
+
+
+
+ 01 / Server
+ Backend files
+ Controllers, routes, models, middleware, schema files, callbacks, and service logic.
+
+ 03 / Structure
+ App conventions
+ Generated files land where Leaf MVC expects them, so the project stays predictable.
+
+
+ 04 / Context
+ AI context
+ Agents inspect the generated feature and sync its new structure into Leaf's shared project context.
+
+
-Authentication with Leaf is straightforward and is powered by [Leaf Auth](/docs/auth/) which provides a simple way to authenticate users in your application, plus other essentials like middleware, password hashing, and user management, all out of the box. Leaf MVC's scaffolding tool takes this a step further by allowing you to scaffold an entire authentication system with models, controllers, routes and even views that use your configured frontend tooling.
+If you are using Claude, Codex, or another assistant, you can ask it to scaffold a feature and then customize the generated files around your product requirements.
-You can get started using the `scaffold:auth` command:
+Every feature scaffold ships in Blade, React, Vue, and Svelte variants. Leaf detects which one to use from your app's Inertia setup, so scaffolding in a React app generates React pages without any extra flags. To pick a variant yourself, pass `--scaffold react`, `--scaffold vue`, `--scaffold svelte`, or `--scaffold default` for Blade.
+
+## Authentication
+
+Authentication with Leaf is powered by [Leaf Auth](/docs/auth/), which gives you login, registration, sessions, password hashing, user management, and route protection.
```bash:no-line-numbers
leaf scaffold:auth
```
-The scaffold:auth command sets up a fully functional authentication system, including:
-
-- User model with a database schema file
-- Authentication controllers (login, register, dashboard)
-- Authentication routes
-- Middleware for route protection
-- Views tailored to your frontend setup
-- Dashboard tailored to your frontend setup
-- Account update example
+
+
+
+
Generated
+
models controllers routes middleware views
+
+
+
What you get
+
+
User model with a database schema file
+
Login, register, dashboard, and account update controllers
+
Authentication routes and route protection middleware
+
Views and dashboard screens tailored to your frontend setup
+
+
+
+
This is automatically done for you if you choose to install the application starter during installation.
-
-
## Landing Page
-Another annoying starting point for most developers is the landing page. Leaf MVC's scaffolding tool allows you to scaffold a landing page with a single command:
+Use the landing page scaffold when you want a polished starting point for your product homepage.
```bash:no-line-numbers
leaf scaffold:landing-page
@@ -38,31 +118,35 @@ You get:
- A structured homepage layout
- Sections like hero, features, and footers
-- Tailwind for styling + your preferred frontend setup
+- Tailwind styling with your preferred frontend setup
- Easy customization with Leaf Zero components
-
-
## Billing Subscription
-Subscriptions are pretty common in modern applications, but quite annoying to set up. Leaf MVC's scaffolding tool allows you to scaffold a billing subscription system with a single command:
+Subscriptions are common in modern products, but the setup usually involves pricing UI, callbacks, webhooks, database state, and provider configuration. Leaf MVC can scaffold the shape for you.
```bash:no-line-numbers
leaf scaffold:subscriptions
```
-It requires [Leaf Billing](/docs/utils/billing) to be installed, and you get:
+It requires [Leaf Billing](/docs/utils/billing) to be installed.
-- A pricing component in whatever frontend setup you are using
-- Subscription/cancellation controllers
-- Webhooks/callbacks/routes for Stripe
-- Database schema, models and config
-
-
+
+
+ 01 / Customer UI
+ Frontend
+ Pricing component and subscription UI in whatever frontend setup you are using.
+
## Waitlists
-Creating a waitlist/coming soon page is a great way to build anticipation for your product before it launches. It allows you to collect email addresses from interested users, which can be invaluable for marketing and user engagement once your product is live. You can scaffold a waitlist using:
+Waitlists help you validate demand and collect emails before a product is fully open.
```bash:no-line-numbers
leaf scaffold:waitlist
@@ -73,16 +157,103 @@ These will give you:
- Waitlist component for collecting emails in your frontend setup
- Middleware to restrict accidental access to your app
- Models and schema files for email collection
-- Waitlist invites and more
+- Waitlist invites and related starting points
+
+## Blog
+
+The blog scaffold gives you a markdown-powered blog: write posts as markdown files and Leaf renders them with your frontend setup.
+
+```bash:no-line-numbers
+leaf scaffold:blog
+```
-
+You get:
+
+- Markdown rendering (parsedown is installed for you)
+- Blog index and post pages in your frontend setup
+- Controllers and routes for listing and reading posts
+- A posts folder (`app/blog`) you publish to by dropping in markdown files with `title`, `date`, and `description` frontmatter
+
+## Contact form
+
+A contact form that actually sends mail, wired end to end.
+
+```bash:no-line-numbers
+leaf scaffold:contact
+```
+
+You get:
+
+- A contact page in your frontend setup
+- A controller that validates submissions and sends the message with [Leaf Mail](/docs/utils/mail/) (installed and configured for you if missing)
+- Routes wired up and ready to restyle
+- `CONTACT_EMAIL` added to your `.env` as the delivery inbox, with `MAIL_SENDER_EMAIL` as the fallback
+
+## Legal pages
+
+Every product eventually needs them, and nobody enjoys writing them from a blank file.
+
+```bash:no-line-numbers
+leaf scaffold:legal
+```
+
+You get privacy policy and terms of service pages in your frontend setup, wired to your `APP_NAME` and `CONTACT_EMAIL` env values. The copy has clearly marked EDIT ME sections, so you (or your lawyer) only fill in the product-specific parts.
+
+## AI chat
+
+Scaffold a streaming AI chat powered by Claude: a full chat page with streamed responses, not just an API call.
+
+```bash:no-line-numbers
+leaf scaffold:ai
+```
+
+You get:
+
+- A chat interface in your frontend setup with streaming responses
+- Server routes that proxy to the Anthropic API
+- `ANTHROPIC_API_KEY` added to your `.env` / `.env.example`. Drop your key in and visit `/ai`
+
+## Mail setup
+
+Not a feature scaffold, but a shortcut: installs [Leaf Mail](/docs/utils/mail/) and generates your mail config in one step.
+
+```bash:no-line-numbers
+leaf scaffold:mail
+```
+
+## shadcn/ui
+
+If you're pairing React with your Leaf app, this sets up [shadcn/ui](https://ui.shadcn.com/) so you can install any of its components:
+
+```bash:no-line-numbers
+leaf scaffold:shadcn
+```
+
+```bash:no-line-numbers
+pnpm dlx shadcn@latest add switch
+```
+
+## AI-assisted scaffolding
+
+Scaffolding pairs naturally with AI because the command gives your assistant a working feature shape to edit instead of asking it to invent every file from scratch.
+
+```txt:no-line-numbers
+Ask your assistant:
+"Scaffold auth, then customize the dashboard for a SaaS admin."
+```
+
+Recommended workflow:
+
+1. Run or ask your assistant to run the scaffold command.
+2. Ask for the product-specific changes.
+3. Review the generated routes, controllers, models, and views.
+
+If the assistant cannot access the project folder, run `leaf context` after scaffolding and paste the compact output into your conversation.
## More coming soon
We are working on scaffolding for more features like:
-- Blog
- Admin panel
-- API Dashboard & more.
-
-We'll be adding these in the future, so stay tuned!
+- API dashboard
+- More product starters
diff --git a/src/docs/mvc/services.md b/src/docs/mvc/services.md
index 07760df5..e26373bd 100644
--- a/src/docs/mvc/services.md
+++ b/src/docs/mvc/services.md
@@ -1,12 +1,12 @@
-# Services New
+# Services
Services let you encapsulate business logic and make it reusable across your application. For example, you might have functionality in `StatsController` that you want to use in `DashboardController` or expose via an API. Instead of duplicating code, you can create a service class and inject it where needed.
## Creating a Service
-Services are just plain PHP classes β no base class or interface required. By convention, we keep them in `app/services`, but you can place them anywhere in your project. Let's create our `StatsService` from the earlier example:
+Services are just plain PHP classes, with no base class or interface required. By convention, we keep them in `app/services`, but you can place them anywhere in your project. Let's create our `StatsService` from the earlier example:
```php
1500,
@@ -55,13 +55,13 @@ In this version of Leaf, `make()` simply initializes the service class just like
:::
-## Why no dependency Injection?
+## Why no dependency injection?
-If youβre coming from frameworks like Laravel, you might expect to inject services through constructors or method injection. While this is powerful, it also adds extra complexity.
+If youβre coming from frameworks like Laravel, you might expect to inject services through constructors or method injection. That approach works, but it adds extra complexity to most mid-sized projects.
-In Leaf, almost everything you need is already accessible through global functions, so thereβs no need to inject dependencies just to use them. To keep things simple and consistent, Leaf uses make() to resolve services.
+In Leaf, almost everything you need is already accessible through global functions, so there's no need to inject dependencies just to use them. To keep things simple and consistent, Leaf uses `make()` to resolve services. It's more of service location rather than dependency injection which Leaf already does through `app()->register()`. It is a deliberate trade, and your service classes can still take plain constructor arguments whenever you want explicit wiring.
-Instead of wiring dependencies into constructors, you can simply call a function (`make()`, `cache()`, `response()`, etc.) to get what you need β anywhere in your app.
+Instead of wiring dependencies into constructors, you can simply call a function (`make()`, `cache()`, `response()`, etc.) to get what you need, anywhere in your app.
## When to Use a Service
diff --git a/src/docs/routing/dynamic.md b/src/docs/routing/dynamic.md
index 0aab9b33..29f8e432 100644
--- a/src/docs/routing/dynamic.md
+++ b/src/docs/routing/dynamic.md
@@ -4,11 +4,11 @@ Dynamic routing allows your app to handle different URLs by using placeholders o
An example of dynamic routing is your user profile on Twitter and YouTube. Your profile URL is `https://twitter.com/username` or `https://youtube.com/@username`. The `username` part of the URL is dynamic and can be anything, but it is able to show the profile of the user with that username.
-Leaf allows you to create dynamic routes using placeholders or regular expressions. Placeholders are easier to use, while regular expressions give you more control over the route pattern.
+Leaf routes are compiled when they are registered, so dynamic routes stay fast no matter how many you define. Everything is built from one primitive: the named placeholder.
## Named Placeholders
-Named placeholders are strings surrounded by curly braces, e.g. `{name}`. They are easy to use and are translated to regular expressions that match any character. They are also easier to read and understand, which makes them great for simple dynamic routes.
+Named placeholders are strings surrounded by curly braces, e.g. `{name}`. They match any value in that segment of the URL and are easy to read at a glance.
Examples:
@@ -41,119 +41,67 @@ app()->get('/movies/{foo}/photos/{bar}', function ($movieId, $photoId) {
});
```
-In the example above, `$movieId` will contain the value of the `foo` placeholder, while `$photoId` will contain the value of the `bar` placeholder.
+In the example above, `$movieId` will contain the value of the `foo` placeholder, while `$photoId` will contain the value of the `bar` placeholder, matching the order they appear in the route pattern.
-## Regular Expressions
+## Optional Parameters
-Regular expressions (specifically PCREs) allow you to create more complex route patterns that Leaf can match against. This gives you more control over the route pattern and allows you to create more specific routes. For instance, you can create a route that only matches numbers or email addresses.
-
-Examples:
-
-- `/movies/(\d+)`
-- `/profile/(\w+)`
-
-You can use regular expressions in your routes by adding them to the route pattern. When a request is made to the route, Leaf will match the URL against the regular expression and pass the matched values to the route handling function.
+Adding `?` to a placeholder makes it optional, so one route can respond to different variations of the same URL.
```php
-app()->get('/movies/(\d+)', function ($id) {
- echo 'This is the page for movie #' . $id;
-});
-```
+app()->get('/posts/{id?}', function ($id = null) {
+ if (!$id) {
+ echo 'All posts';
+ return;
+ }
-If you have multiple placeholders in your route pattern, Leaf will pass the matched values to the route handling function in the order they appear in the route pattern.
-
-```php
-app()->get('/movies/(\d+)/photos/(\d+)', function ($movieId, $photoId) {
- echo 'Movie #' . $movieId . ', photo #' . $photoId;
+ echo 'Post #' . $id;
});
```
-In the example above, `$movieId` will contain the value of the first placeholder, while `$photoId` will contain the value of the second placeholder.
-
-If you are a fan of finer control, regular expressions are the way to go. You can check out the [PHP PCRE documentation](https://github.com/cornernote/cheat-sheet/blob/master/PHP%20PCRE%20Cheat%20Sheet.pdf) for more information on regular expressions in PHP.
+This route responds to both `/posts` and `/posts/42`. When the optional parameter is missing, your handler receives nothing for it, so give the argument a default value like `$id = null` above.
-Here are a few examples of common PCRE subpatterns to get you started:
-
-- \d+ = One or more digits (0-9)
-- \w+ = One or more word characters (a-z 0-9 _)
-- [a-z0-9_-]+ = One or more word characters (a-z 0-9 _) and the dash (-)
-- .* = Any character (including /), zero or more
-- [^/]+ = Any character but /, one or more
-
-While parentheses `()` are not required, they are a good way to keep your code clean and readable. They also allow you to group parts of the regular expression together, which can be useful for more complex patterns.
+You can chain optional parameters to handle whole URL families with one route:
```php
-app()->get('/movies/(\d+)', function ($id) {
- echo 'This is the page for movie #' . $id;
-});
-```
-
-## Optional Route Sub-patterns
-
-Optional Route Sub-patterns allow parts of a route to be, well, optional! This means a user can visit different variations of the same URL, and your app will still respond correctly.
+app()->get('/blog/{year?}/{month?}/{slug?}', function ($year = null, $month = null, $slug = null) {
+ if (!$year) {
+ echo 'Blog overview';
+ return;
+ }
-For example, you could have a route like `/post/{id}(/edit)?`, where `{id}` is required, but `/edit` is optional. This allows both /post/1 and /post/1/edit to be valid routes. The optional part is denoted by the `?` character.
-
-```php
-app()->get('/post/{id}(/edit)?', function () {
- echo 'Hello this is a post';
+ // ...
});
```
-While this might seem simple, it's important to note that the optional part should be inside the sub-pattern itself. The leading `/` of the sub-pattern should be inside the sub-pattern otherwise, you might end up with unexpected results.
+This responds to `/blog`, `/blog/2026`, `/blog/2026/07`, and `/blog/2026/07/my-post`. It cuts down the number of routes you define, though separate routes are often easier to read once the handler starts branching heavily.
-### Using Regular Expressions
+## Constraints
-The example above showed `/post/{id}(/edit)?`, but you can also use regular expressions to define optional sub-patterns. For example, you could have `/post/(\d+)(/edit)?` to match `/post/1` and `/post/1/edit`. You can also use more complex regular expressions to match more complex URLs. Let's look at an example:
+By default a placeholder matches anything in its URL segment. Adding a constraint after a colon restricts what the placeholder accepts. The constraint is a regular expression that must match the whole value.
```php
-app()->get('/blog(/\d+)?(/\d+)?(/\d+)?(/[a-z0-9_-]+)?', function ($year = null, $month = null, $day = null, $slug = null) {
- if (!$year) {
- echo 'Blog overview';
- return;
- }
-
- if (!$month) {
- echo 'Blog year overview';
- return;
- }
-
- if (!$day) {
- echo 'Blog month overview';
- return;
- }
-
- if (!$slug) {
- echo 'Blog day overview';
- return;
- }
-
- echo 'Blogpost ' . htmlentities($slug) . ' detail';
+app()->get('/movies/{id:[0-9]+}', function ($id) {
+ echo 'This is the page for movie #' . $id;
});
```
-With this example, we can respond to URLs like `/blog`, `/blog/{year}`, `/blog/{year}/{month}`, `/blog/{year}/{month}/{day}`, and `/blog/{year}/{month}/{day}/slug` all with a single route. This is a powerful feature that can significantly reduce the number of routes you need to define, however, it has the downside of being harder to read and understand compared to defining separate routes.
+Now `/movies/123` matches, but `/movies/abc` returns a 404 instead of reaching your handler. Constraints combine with optional parameters too: `{id?:[0-9]+}` is a digits-only parameter that may be missing (the `?` goes right after the name, before the constraint).
-### Successive Optional Sub-patterns
+Here are a few common constraints to get you started:
-This scary-sounding term means that you can have multiple optional sub-patterns in a row. This is done by nesting the optional sub-patterns inside each other. This is important because it ensures that the optional parts are correctly matched. For example, the example above which is `/blog(/\d+)?(/\d+)?(/\d+)?(/[a-z0-9_-]+)?` can respond to `/blog/somecrazystring` which is not what we want. To fix this, we can nest the optional sub-patterns like this:
+- `{id:[0-9]+}` = one or more digits (0-9)
+- `{slug:[a-z0-9_-]+}` = lowercase word characters and dashes
+- `{year:[0-9]{4}}` = exactly 4 digits
+- `{username:\w+}` = one or more word characters (a-z 0-9 _)
-```php
-app()->get('/blog(/\d+(/\d+(/\d+(/[a-z0-9_-]+)?)?)?)?', function ($year = null, $month = null, $day = null, $slug = null) {
- // ...
-});
-```
-
-What we've done here is place the sub-patterns inside each other, instead of leaving them next to each other. This ensures that the optional parts are correctly matched and that the route only responds to the correct URLs. Now accessing `/blog/somecrazystring` will not match this route.
+Using quantifiers like `{4}` lets you require exact formats: `/blog/{year:[0-9]{4}}/{month:[0-9]{2}}` responds to `/blog/2026/07` but not `/blog/17819090091/07`. You can read more about quantifiers in the [PHP documentation](https://www.php.net/manual/en/regexp.reference.repetition.php).
-### Quantifiers
+## How routes are matched
-In the examples above, we used `\d+` to match one or more digits. You can use quantifiers to require a specific number of digits in the URL. For example, you could use `\d{4}` to match exactly 4 digits. This can be useful when you want to ensure that the URL matches a specific format. You can read more about quantifiers in the [PHP documentation](https://www.php.net/manual/en/regexp.reference.repetition.php). Let's update our example to require exactly 4 digits for the year, 2 digits for the month, and 2 digits for the day:
+Exact routes always win over dynamic ones. If both `/users/new` and `/users/{id}` are registered, a request to `/users/new` runs the exact route no matter which was registered first. Exact matches are resolved from an index without touching any patterns at all.
-```php
-app()->get('/blog(/\d{4}(/\d{2}(/\d{2}(/[a-z0-9_-]+)?)?)?)?', function ($year = null, $month = null, $day = null, $slug = null) {
- // ...
-});
-```
+When two dynamic routes could match the same URL, the one registered first wins, so declare your more specific dynamic routes before broader ones.
-This ensures that the route only responds to URLs like `/blog/2021/01/01/slug` and not `/blog/17819090091/01/01/slug`.
+::: warning Coming from Leaf 3 or 4?
+Older versions of Leaf allowed raw regular expressions as route patterns, like `/movies/(\d+)` or `/post/{id}(/edit)?`. These are no longer supported: patterns without `{}` placeholders are treated as literal paths. Rewrite them with placeholders, optional parameters, and constraints; the [upgrade guide](/docs/upgrade-guide) has side-by-side examples.
+:::
diff --git a/src/docs/routing/error-handling.md b/src/docs/routing/error-handling.md
index 4b573d6f..118dfbd1 100644
--- a/src/docs/routing/error-handling.md
+++ b/src/docs/routing/error-handling.md
@@ -4,23 +4,60 @@
It's super hard to get everything right the first time, trust us, we know! This could be due to typos, wrong logic, or other unforeseen issues from external services. In such cases, it's important to handle errors gracefully and provide useful feedback to users.
-## Error Screens
+## The crash screen
-When an error occurs in your Leaf application, you want to make sure that you see a friendly error message with a trace instead of a raw error dump. This error screen gives you some context about the error and what might have caused it.
+When something breaks during development, Leaf shows a crash report instead of a raw error dump. It carries the stack with code excerpts, but also the story around the crash: the request, the signed-in user, and the steps that led there.
-
+
-It also adds information about your application's current context:
+Every report includes:
-- Application environment (development, production, etc.)
-- Request information (server data, method, headers, etc.)
-- Files, cookies, session info, and more.
+- The exception, with vendor frames collapsed and code excerpts for every frame
+- A fingerprint that identifies this kind of crash across occurrences
+- Request, app, and signed-in user context
+- The user journey: what the app was doing before it broke
+- One-click actions: copy the report as markdown, open it in Claude or ChatGPT, download it as JSON, replay the request as cURL, or jump to the crash line in your editor
-Which is why we recommend that you always turn off error reporting in production, so you don't accidentally leak sensitive information about your application.
+The screen follows your system theme, works with no internet connection, and secrets (passwords, tokens, cookies, card numbers) are stripped before the report is even built, so nothing that renders or ships can leak them.
+
+## The user journey
+
+Leaf records what your app is doing as it runs: requests from the router, every database query (from both `db()` and your models), log lines, cache misses, outgoing HTTP calls from `fetch()`, and view renders. When a crash happens, that trail is right on the report.
+
+
+
+You can add your own steps for the moments only your code understands:
+
+```php:no-line-numbers
+crash()->leaveCrumb('coupon applied', 'action', ['total_after' => $total]);
+```
+
+Clicking a step on the crash screen expands it in place: the attached data as a collapsible tree, when it happened, and which function recorded it. Recording a step costs about a microsecond, so the journey is always on without slowing your app down.
+
+## Checkpoints: debugging without an exception
+
+Some bugs never throw. The checkout "works" and the total is somehow zero. Drop a checkpoint into the flow and Leaf files a full report, exactly like a crash: stack trace starting at your call site, journey, context, and any variables you want to see.
+
+```php:no-line-numbers
+if ($order['total'] <= 0 && count($order['items']) > 0) {
+ crash()->capture('order total is 0 but cart has items', [
+ 'level' => 'warning',
+ 'peeks' => ['order' => $order],
+ ]);
+}
+```
+
+`peeks` snapshots variables safely: depth, size, and string limits are applied when the value is captured, and secrets are masked like everywhere else.
+
+## Debugging with AI
+
+The "Open with AI" menu on the crash screen builds a briefing from your project's `.leaf/CONTEXT.md`, the user journey, and the crash itself with code, then opens it in Claude or ChatGPT with one click. You can also copy the prompt or download the whole report as JSON to use with any tool.
+
+This works noticeably better than pasting a stack trace: the journey shows the AI what the user did, not just where the code stopped.
## Disabling Error Reporting
-While Leaf's detailed error reporting is super useful during development, it's not something you want to use in production, as it can expose sensitive information about your application. You can disable error reporting by setting the `debug` config to `false` or by setting the `APP_DEBUG` environment variable to `false` in Leaf MVC.
+While Leaf's detailed crash screen is super useful during development, it's not something you want in production, as it can expose information about your application. In Leaf 5, setting `APP_ENV=production` turns debug output off by default. You can also disable it explicitly with the `debug` config or the `APP_DEBUG` environment variable in Leaf MVC.
::: code-group
@@ -30,13 +67,13 @@ app()->config([
]);
```
-```env:no-line-numbers [Leaf MVC]
+```txt:no-line-numbers [Leaf MVC]
APP_DEBUG=false
```
:::
-When you set `debug` to `false`, Leaf will automatically turn off error reporting and display a custom error page to users. You can customize this page using Leaf's `setErrorHandler()` method.
+With `debug` off, users see a clean branded error page with no internals, and the full report still goes to your logs and any reporting services you attach. You can replace the page with your own using `setErrorHandler()`:
```php:no-line-numbers
app()->setErrorHandler(function () {
@@ -48,20 +85,22 @@ We understand that you might want to enable debugging in production for some rea
## Logging
-Logs are records of events in your application. They capture significant things like errors, requests, or user actions, helping you track your app's behavior. Log files are essential for debugging and understanding production issues. A typical log file looks like this:
+Logs are records of events in your application. They capture significant things like errors, requests, or user actions, helping you track your app's behavior.
+
+Log files are essential for debugging and understanding production issues. A typical log file looks like this:
```log{4-5}
[2021-03-31 22:44:53]
-ERROR - ErrorException: Trying to access array offset on value of type int in /home/mychi/Projects/leafphp/leaf/src/Experimental/Cache.php:83
+ERROR - ErrorException: Trying to access array offset on value of type int in /home/mychi/Projects/leafphp/app/controllers/OrdersController.php:83
Stack trace:
-#0 /home/mychi/Projects/leafphp/leaf/src/Experimental/Cache.php(83): Leaf\Exception\General::handleErrors()
-#1 /home/mychi/Projects/leafphp/leaf/test/index.php(45): Leaf\Experimental\Cache::get()
+#0 /home/mychi/Projects/leafphp/app/controllers/OrdersController.php(83): Leaf\Exception\General::handleErrors()
+#1 /home/mychi/Projects/leafphp/app/routes/index.php(45): App\Controllers\OrdersController->show()
#2 [internal function]: {closure}()
-#3 /home/mychi/Projects/leafphp/leaf/src/Router.php(337): call_user_func_array()
-#4 /home/mychi/Projects/leafphp/leaf/src/Router.php(392): Leaf\Router::invoke()
-#5 /home/mychi/Projects/leafphp/leaf/src/Router.php(443): Leaf\Router::handle()
-#6 /home/mychi/Projects/leafphp/leaf/src/App.php(863): Leaf\Router::run()
-#7 /home/mychi/Projects/leafphp/leaf/test/index.php(52): Leaf\App->run()
+#3 /home/mychi/Projects/leafphp/vendor/leafs/leaf/src/Router.php(337): call_user_func_array()
+#4 /home/mychi/Projects/leafphp/vendor/leafs/leaf/src/Router.php(392): Leaf\Router::invoke()
+#5 /home/mychi/Projects/leafphp/vendor/leafs/leaf/src/Router.php(443): Leaf\Router::handle()
+#6 /home/mychi/Projects/leafphp/vendor/leafs/leaf/src/App.php(863): Leaf\Router::run()
+#7 /home/mychi/Projects/leafphp/public/index.php(52): Leaf\App->run()
#8 {main}
```
@@ -130,9 +169,9 @@ That's it! Leaf will no longer log errors or exceptions for your app.
:::
-## Rescue Helper New
+## Rescue Helper
-Leaf provides an elegant way to handle exceptions using the `rescue()` function. This function automically catches any exceptions thrown within the provided callback and logs them if logging is enabled, and then returns a default value. This way, you can use try-catch with a more inline syntax.
+Leaf provides a shorter way to handle exceptions using the `rescue()` function. It runs your callback, catches anything thrown inside it, reports the exception to Crash, and returns a default value. This way, you can use try-catch with a more inline syntax.
```php
$someRiskyOperation = function () {
@@ -142,74 +181,37 @@ $someRiskyOperation = function () {
$someValue = rescue($someRiskyOperation, 'default value');
```
-In this example, if `$someRiskyOperation()` throws an exception, the `rescue()` function will catch it, log it if logging is enabled, and return `'default value'` instead. This is particularly useful for handling operations that may fail, such as database queries or API calls, without having to write verbose try-catch blocks, and is still useful even when you don't have to return a value.
+In this example, if `$someRiskyOperation()` throws an exception, `rescue()` catches it and returns `'default value'` instead. This is particularly useful for operations that may fail, such as database queries or API calls, without having to write verbose try-catch blocks, and is still useful even when you don't have to return a value.
```php
-rescue(function() {
+rescue(function () {
// Code that may throw an exception
});
```
-In this case, if the callback throws an exception, it will be caught and logged if logging is enabled, and no visible error will be shown to the user. This is useful for important operations that you want to attempt, but don't want to disrupt the user experience if they fail. For example, sending an email notification or logging user activity.
-
-## Leaf DevTools
-
-Leaf provides DevTools to give you more insight into your app than you can get from the error page. It has a beautiful and intuitive interface that give you information about your Leaf application, and a light-weight library that you can use to interact with the devtools frontend.
-
-
-
-To get started with the DevTools, you need to install the Leaf DevTools module:
-
-::: code-group
-
-```bash:no-line-numbers [Leaf CLI]
-leaf install devtools
-```
-
-```bash:no-line-numbers [Composer]
-composer require leafs/devtools
-```
-
-:::
-
-After installing the devtools module, you need to add the hook to your app. This will register the devtools routes and allow your Leaf app to communicate with the DevTools. You can do this by adding this line to your app root.
+Here the exception is caught and no error reaches the user. This suits work you want to attempt but never let break the page, like sending a notification email or recording activity.
-```php{5}
- $api->fetchUser($id),
+ fn ($e) => ['name' => 'Unknown', 'error' => $e->getMessage()]
+);
```
-From there, you can access the DevTools by visiting `/leafDevTools`. The DevTools will show you information about your app, like the routes, the request and response, and the environment variables. You can also use the DevTools to interact with your app, like making requests to your app and seeing the response.
+### Rescued exceptions still reach you
-### Server Debug Logs
+A rescued exception is handled, not invisible. Leaf reports it at `warning` level and drops a breadcrumb into [the user journey](#the-user-journey), so it reaches your reporters and shows up as context on the crash page if something else fails later in the same request.
-When working with JavaScript, you can use `console.log` to log information to the console. In PHP, you can use `echo` or `var_dump` to log information to the browser. However, this can be a bit cumbersome, especially when you're working with APIs or other server-side code. Leaf provides a `log` function that you can use to log information to the server. This is useful for debugging your app in a non-invaisive way.
-
-```php
-\Leaf\DevTools::console('This data should be logged in the console');
-```
+That matters because swallowed exceptions are how bugs hide. The empty catch block that "fixed" a problem in staging is the one you want to see when a real failure happens next to it.
-Adding this line to your code will log the data to the Leaf DevTools console without affecting the output of your app. This allows you to debug your app while going through the normal flow of your app.
+If a particular `rescue()` is noisy and you genuinely don't want it recorded, pass `false` as the third argument:
```php
-\Leaf\DevTools::console('This data should be logged in the console');
-\Leaf\DevTools::console('This is a warning', 'warn');
-\Leaf\DevTools::console('This is an error', 'error');
-\Leaf\DevTools::console('This is an info message', 'info');
-\Leaf\DevTools::console('This is a debug message', 'log');
+rescue(fn () => $cache->warm(), null, false);
```
-These will output different colored messages in the console:
-
-
-
-***Leaf will only allow access to the DevTools when the app is in a development environment, but not every hosting provider sets the environment to `production` automatically. To be safe, we recommend uninstalling the DevTools module before deploying your app.***
-
## Maintenance Mode
There are times where you need to take your application down for maintenance. This may be due to updates or other external reasons. Putting your application in down mode will display a maintenance message to users, and prevent them from accessing your application.
@@ -235,7 +237,5 @@ app()->setDown(function () {
You can use this method to display a custom html page or any other content you want to show users when your application is in down mode.
```php
-app()->setDown(function () {
- response()->page('./down.html');
-});
+app()->setDown(fn () => response()->page('./down.html'));
```
diff --git a/src/docs/routing/index.md b/src/docs/routing/index.md
index 7d7f7c96..de0d506b 100644
--- a/src/docs/routing/index.md
+++ b/src/docs/routing/index.md
@@ -2,52 +2,68 @@
-
-
-Routing is the foundation of every web application. It's the process of defining the URL structure of your application and how it responds to requests. Leaf comes with a powerful router that simplifies the way you define routes in your application. You can take routing as one fancy traffic officer that directs traffic to the right place.
-
-
+
+
+
+
HTTP routing
+
Turn URLs into application behavior with a tiny, readable router.
+
Leaf routes define the URL, HTTP method, and handler for each request. The API stays small enough to read quickly, while still supporting named routes, redirects, 404 handling, dynamic routes, middleware, groups, and MVC controllers.
+
+
+
+
+
+
+
+ routes/index.php
+
+
+
app()->get('/home', function () {
+
return response()->json(['ok' => true]);
+
});
+
+
+
+
+
Routing covers
+
+
+
Methods
+
GET, POST, PUT, PATCH, DELETE, and multi-method routes.
+
+
+
Flow
+
Names, redirects, current route data, custom 404s, and middleware.
+
+
+
AI context
+
Routes are one of the first maps assistants use to understand an app.
+
+
+
+
+
## Create a route
-
-
-
-
- Using Leaf MVC?
-
-
- We've crafted a specialized guide for routing in Leaf MVC. While it's similar to the basic routing in Leaf, it's more detailed and tailored for Leaf MVC.
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
Route β Controller β Response
+
Building with Leaf MVC? Keep routes thin and move application logic into controllers.
Every route has a URL (the web address the user visits) and an HTTP method (like GET, POST, etc.), which tells the server what action to take. For example, if you create a route for a GET request to `/home`, the user can access that page by visiting `http://example.com/home`. This way, different URLs and methods control how users interact with your app.
@@ -164,26 +180,20 @@ Leaf displays a default 404 screen when it can't find a page that a user wants t
You can customize the 404 page using Leaf's `set404()` method.
```php
-app()->set404(function () {
- response()->json([
- "error" => "Page not found"
- ]);
-});
+app()->set404(fn () => response()->json([
+ "error" => "Page not found"
+]));
```
Once this is set, Leaf will automatically use your custom 404 page when a user tries to access a page that doesn't exist in your app.
## Named routes
-In big applications, you might have to reference a route over and over again. When you change the route URL, you'll have to change it everywhere you referenced it. To avoid this, you can name your routes and reference them by their name. This will save you a lot of time and prevent errors.
+In big applications, you might have to reference a route over and over again. When you change the route URL, you'll have to change it everywhere you referenced it.
-Leaf router allows you name routes by using route params. They allow you add extra options to your routes like a route name, middleware, etc. You can set route options by passing an array with configuration options as the second argument to the whatever route you are working on.
+To avoid this, you can name your routes and reference them by their name. This will save you a lot of time and prevent errors.
-
+Leaf router allows you name routes by using route params. They allow you add extra options to your routes like a route name, middleware, etc. You can set route options by passing an array with configuration options as the second argument to the whatever route you are working on.
```php
app()->get('/home', ['name' => 'home', function () {
@@ -197,20 +207,16 @@ You can then redirect to this route using the route name by passing an array wit
response()->redirect(['home']);
```
-If you want to get details about a route using its name, you can use the `route()` method.
+Route groups can carry a `name` too, which prefixes every named route inside (`admin` + `dashboard` β `admin.dashboard`), and resource routes name themselves automatically. See [named groups](/docs/routing/route-groups#named-groups).
+
+To build a URL from a route name (for links, redirects, or anywhere you'd otherwise hardcode a path), use the `route()` method. Parameters fill in the route's placeholders:
```php:no-line-numbers
-$route = app()->route($routeName);
+$url = app()->route('home'); // /home
+$url = app()->route('users.show', ['id' => 5]); // /users/5
```
-This will return an array containing the following information:
-
-- `pattern`: The route pattern
-- `path`: The route path
-- `name`: The route name
-- `method`: The route method
-- `handler`: The route handler
-- Any other route options
+(If you need the full details of the *current* route, like its pattern, name, method, and handler, that's `getRoute()`, shown below.)
## Getting the current route
diff --git a/src/docs/routing/middleware/index.md b/src/docs/routing/middleware/index.md
index 09bcdd6e..e25726c4 100644
--- a/src/docs/routing/middleware/index.md
+++ b/src/docs/routing/middleware/index.md
@@ -2,47 +2,26 @@
-
-
-Middleware is a piece of code that runs before or after your application processes a request. It helps control the flow of requests and responses. For example, when a user visits a page on your app, you can use middleware can check if the user is logged in and if everything is okay, the request moves on to the next step; if not, the middleware can redirect the user.
-
-Middleware can be used for a variety of tasks, such as authentication, authorization, logging, error handling, session management, and more.
-
-
-
-
-
- Using Leaf MVC?
-
-
- We've crafted a specialized guide for routing in Leaf MVC. While it's similar to the base middleware in Leaf, it's more detailed and tailored for Leaf MVC.
-
-
-
-
+
+
+
+
Request pipeline
+
Run checks before your route does the real work.
+
Middleware lets you protect routes, shape requests, attach context, and short-circuit bad traffic without burying that logic inside every handler.
-
-
+
+
+
app()->use('auth');
+
request -> middleware -> route -> response
+
+
+
Using Leaf MVC?
+
MVC has a dedicated middleware guide with route files and controller-friendly examples.
+
## Middleware in Leaf
@@ -60,8 +39,8 @@ In Leaf, middleware are just functions that are loaded into Leaf. Here's an exam
```php
$logRequest = function () {
- $method = request()->method();
- $uri = request()->uri();
+ $method = request()->getMethod();
+ $uri = request()->getPath();
echo "[$method] $uri\n";
}
@@ -77,8 +56,8 @@ Or you can write this together:
```php
app()->use(function () {
- $method = request()->method();
- $uri = request()->uri();
+ $method = request()->getMethod();
+ $uri = request()->getPath();
echo "[$method] $uri\n";
});
@@ -92,8 +71,8 @@ Passing middleware as a route option will run the middleware only for that route
```php
$middleware = function () {
- $method = request()->method();
- $uri = request()->uri();
+ $method = request()->getMethod();
+ $uri = request()->getPath();
echo "[$method] $uri\n";
};
@@ -121,8 +100,8 @@ It's a bit bulky to write your middleware inline every time you need it. Leaf al
```php
app()->registerMiddleware('logRequest', function () {
- $method = request()->method();
- $uri = request()->uri();
+ $method = request()->getMethod();
+ $uri = request()->getPath();
echo "[$method] $uri\n";
});
@@ -159,13 +138,13 @@ It is necessary in some cases to pass data from middleware to the route handler.
```php{8}
app()->registerMiddleware('logRequest', function ($next) {
- $method = request()->method();
- $uri = request()->uri();
+ $method = request()->getMethod();
+ $uri = request()->getPath();
echo "[$method] $uri\n";
// pass data to the next handler
- response()->next('You can pass any value here');
+ return response()->next('You can pass any value here');
});
```
@@ -183,10 +162,8 @@ Once the data is read using `request()->next()`, it is removed from the request
## Middleware with MVC
-We've crafted a specialized guide for routing in Leaf MVC. While it's similar to the base middleware in Leaf, it's more detailed and tailored for Leaf MVC.
+There's a separate guide for middleware in Leaf MVC. It covers the same ideas, with more detail and MVC-specific examples.
-
+
diff --git a/src/docs/routing/middleware/mvc.md b/src/docs/routing/middleware/mvc.md
index f0a87e6a..0fb40201 100644
--- a/src/docs/routing/middleware/mvc.md
+++ b/src/docs/routing/middleware/mvc.md
@@ -7,41 +7,29 @@ prev: false
# Middleware in Leaf MVC
-Middleware is a piece of code that runs before or after your application processes a request. It helps control the flow of requests and responses. For example, when a user visits a page on your app, you can use middleware can check if the user is logged in and if everything is okay, the request moves on to the next step; if not, the middleware can redirect the user.
-
-
-
-
-
-
- Before writing custom middleware, check out Leaf Modulesβthey offer built-in functionality that might already cover your needs, saving you time and effort.
-
-
-
-
-
+
+
+
+
MVC request flow
+
Put route checks in classes your app can reuse.
+
Middleware in Leaf MVC gives auth checks, logging, permissions, and request context a clear place to live before the controller runs.
+
+
+
$ leaf g:middleware LogRequest
+
app()->use(LogRequestMiddleware::class);
+
+
+
Check modules first
+
Auth, CORS, CSRF, and other modules already ship common middleware behavior, which also gives AI clearer app context.
+Middleware is a piece of code that runs before or after your application processes a request. It helps control the flow of requests and responses. For example, middleware can check whether a user is logged in before a controller is allowed to run.
+
## Creating Middleware
In Leaf MVC, middleware are just classes that extend the `Leaf\Middleware` class. Here's an example of a middleware that logs the request method and URI:
@@ -57,8 +45,8 @@ class LogRequestMiddleware extends Middleware
{
public function call()
{
- $method = request()->method();
- $uri = request()->uri();
+ $method = request()->getMethod();
+ $uri = request()->getPath();
echo "[$method] $uri\n";
}
@@ -131,8 +119,8 @@ class LogRequestMiddleware extends Middleware
{
public function call($next)
{
- $method = request()->method();
- $uri = request()->uri();
+ $method = request()->getMethod();
+ $uri = request()->getPath();
echo "[$method] $uri\n";
@@ -161,9 +149,11 @@ class MyController extends Controller
Once the data is read using `request()->next()`, it is removed from the request object and cannot be accessed again during the request lifecycle.
-## Controller Middleware New
+## Controller Middleware
+
+The middleware we have seen so far is applied to routes, which is great for most use-cases. However, there are times when you may want to apply middleware to one or more controller methods, instead of individual routes. This is especially useful when you have an application which has both web and API routes, and you want to apply different middleware to each.
-The middleware we have seen so far is applied to routes, which is great for most use-cases. However, there are times when you may want to apply middleware to one or more controller methods, instead of individual routes. This is especially useful when you have an application which has both web and API routes, and you want to apply different middleware to each. To use this, find the controller you want to add middleware to, and add a `__middleware` method to it:
+To use this, find the controller you want to add middleware to, and add a `__middleware` method to it:
```php
-
-
-Routing is at the heart of every web application, mapping URLs to functionality. Leafβs powerful router keeps this process simple and intuitive, helping you define routes with minimal effortβlike a smart traffic controller seamlessly directing requests.
+
+
+
+
Leaf MVC routing
+
Keep every URL connected to the controller that owns it.
+
Leaf MVC keeps routes readable as your app grows: split them into small partials, point them at controllers, and keep the map clear enough for your team and AI tools to follow.
+
## Route partials
-In Leaf MVC, all routes are defined in partials within the app/routes directory. Partials are simple PHP files prefixed with `_`, like `_auth.php` or `_api.php`. Thereβs no special syntaxβjust a clean, structured way to keep your code organized as your app or API scales.
+In Leaf MVC, all routes are defined in partials within the app/routes directory. Partials are simple PHP files prefixed with `_`, like `_auth.php` or `_api.php`. Thereβs no special syntax, just a simple way to keep your code organized as your app or API grows.
-To add a new route, simply place it in the relevant partial or create a new one if it doesnβt fit into an existing group. This keeps your routing intuitive and easy to manage.
+To add a new route, place it in the relevant partial, or create a new one if it doesnβt fit into an existing group.
-
-
-
-
-
-
- Leaf MVC is just like Leaf is as unopinionated as it gets, so if you are anti-partials, you can define all your routes in the `app/routes/index.php` file.
-
-
-
-
-
-
-
+
+ Prefer one file? You can still define everything in app/routes/index.php. Partials are there when your app needs a cleaner map, not because Leaf forces a folder ritual.
+
## Breaking down routes
@@ -143,13 +142,7 @@ app()->view('/home', 'home');
## Named routes
-In larger applications, managing routes efficiently is key. Leaf lets you name routes, so you can reference them by name instead of hardcoding URLs, making updates easier. You can also define options like middleware using an array as the second argument when setting up a route, keeping your code flexible and maintainable.
-
-
+Leaf lets you name routes, so you can reference them by name instead of hardcoding URLs. When a URL changes, you only update it in one place. You can also define options like middleware using an array as the second argument when setting up a route.
```php:no-line-numbers
app()->get('/home', ['name' => 'home', 'HomeController@index']);
@@ -161,20 +154,16 @@ You can then redirect to this route using the route name by passing an array wit
response()->redirect(['home']);
```
-If you want to get details about a route using its name, you can use the `route()` method.
+Resource routes name themselves automatically: `app()->resource('/users', 'UsersController')` registers `users.index`, `users.show`, `users.edit` and friends, and group names prefix them (`admin.users.index`). See [named groups](/docs/routing/route-groups#named-groups).
+
+To build a URL from a route name (for links, redirects, or anywhere you'd otherwise hardcode a path), use the `route()` method. Parameters fill in the route's placeholders:
```php:no-line-numbers
-$route = app()->route($routeName);
+$url = app()->route('home'); // /home
+$url = app()->route('users.show', ['id' => 5]); // /users/5
```
-This will return an array containing the following information:
-
-- `pattern`: The route pattern
-- `path`: The route path
-- `name`: The route name
-- `method`: The route method
-- `handler`: The route handler
-- Any other route options
+(If you need the full details of the *current* route, like its pattern, name, method, and handler, that's `getRoute()`, shown below.)
## Getting the current route
@@ -227,9 +216,7 @@ If you need to set up custom error responses, you can do so in the `app/routes/i
| you set will be called when a 404 error is encountered
|
*/
-app()->set404(function () {
- response()->json('Resource not found', 404, true);
-});
+app()->set404(fn () => response()->json('Resource not found', 404, true));
/*
|--------------------------------------------------------------------------
@@ -241,9 +228,7 @@ app()->set404(function () {
| you set will be called when a 500 error is encountered
|
*/
-app()->setErrorHandler(function () {
- response()->json('An error occurred, our team has been notified', 500, true);
-});
+app()->setErrorHandler(fn () => response()->json('An error occurred, our team has been notified', 500, true));
```
## What to read next
diff --git a/src/docs/routing/route-groups.md b/src/docs/routing/route-groups.md
index 6672cc38..d3cb4835 100644
--- a/src/docs/routing/route-groups.md
+++ b/src/docs/routing/route-groups.md
@@ -41,7 +41,7 @@ You can add middleware that should run on every route in a group by passing the
```php
app()->registerMiddleware('auth', function () {
if (!auth()->user()) {
- response()->redirect('/login');
+ return response()->redirect('/login');
}
});
@@ -64,21 +64,45 @@ $middleware = function () {
};
app()->group('/user', ['middleware' => $middleware, function () {
- app()->get('/', function () {
- response()->markup('no user id');
- });
+ app()->get('/', fn () => response()->markup('no user id'));
- app()->get('/(\d+)', function ($id) {
- response()->markup("user $id");
- });
+ app()->get('/(\d+)', fn ($id) => response()->markup("user $id"));
}]);
```
+## Named groups
+
+Groups can carry a `name` that prefixes every named route inside them, so related routes share a hierarchical naming scheme:
+
+```php
+app()->group('/admin', ['name' => 'admin', function () {
+ app()->get('/dashboard', ['name' => 'dashboard', 'AdminController@dashboard']);
+
+ app()->group('/reports', ['name' => 'reports', function () {
+ app()->get('/{id}', ['name' => 'show', 'ReportsController@show']);
+ }]);
+}]);
+
+app()->route('admin.dashboard'); // /admin/dashboard
+app()->route('admin.reports.show', ['id' => 9]); // /admin/reports/9
+```
+
+Resource routes name themselves automatically (`users.index`, `users.show`, `users.edit`, ...), and those names compose with group names too:
+
+```php
+app()->group('/admin', ['name' => 'admin', function () {
+ app()->resource('/users', 'UsersController');
+}]);
+
+app()->route('admin.users.index'); // /admin/users
+app()->route('admin.users.edit', ['id' => 3]); // /admin/users/3/edit
+```
+
## Subfolder Support
-Leaf will run in any subfolder you place it into without a need for any adjustments to your code. You can freely move your entry script `index.php` around, and the router will automatically adapt itself to work relatively from the current folder's path by mounting all routes onto that base path.
+Leaf runs in any subfolder without adjustments to your code: when your app is deployed under `/subdir/` and requests actually arrive as `/subdir/...`, the router detects that base path and mounts your routes onto it automatically. Detection is honest about context: if the request URLs don't live under your script's folder (like with `php -S` or the CLI), nothing is stripped, so local development never eats URI segments.
-While this is okay for most cases, there are very rare cases when you might want to disable this feature. This is possible by manually overriding the base path using `setBasePath()`.
+If you want full control, override detection manually with `setBasePath()`, including `setBasePath('')` for "no base path at all":
```php
// Override auto base path detection
diff --git a/src/docs/routing/sub-patterns.md b/src/docs/routing/sub-patterns.md
deleted file mode 100644
index c7efeba5..00000000
--- a/src/docs/routing/sub-patterns.md
+++ /dev/null
@@ -1,69 +0,0 @@
-# Optional Route Sub-patterns
-
-Optional Route Sub-patterns allow parts of a route to be, well, optional! This means a user can visit different variations of the same URL, and your app will still respond correctly.
-
-For example, you could have a route like `/post/{id}(/edit)?`, where `{id}` is required, but `/edit` is optional. This allows both /post/1 and /post/1/edit to be valid routes. The optional part is denoted by the `?` character.
-
-```php
-app()->get('/post/{id}(/edit)?', function () {
- echo 'Hello this is a post';
-});
-```
-
-While this might seem simple, it's important to note that the optional part should be inside the sub-pattern itself. The leading `/` of the sub-pattern should be inside the sub-pattern otherwise, you might end up with unexpected results.
-
-## Using Regular Expressions
-
-The example above showed `/post/{id}(/edit)?`, but you can also use regular expressions to define optional sub-patterns. For example, you could have `/post/(\d+)(/edit)?` to match `/post/1` and `/post/1/edit`. You can also use more complex regular expressions to match more complex URLs. Let's look at an example:
-
-```php
-app()->get('/blog(/\d+)?(/\d+)?(/\d+)?(/[a-z0-9_-]+)?', function ($year = null, $month = null, $day = null, $slug = null) {
- if (!$year) {
- echo 'Blog overview';
- return;
- }
-
- if (!$month) {
- echo 'Blog year overview';
- return;
- }
-
- if (!$day) {
- echo 'Blog month overview';
- return;
- }
-
- if (!$slug) {
- echo 'Blog day overview';
- return;
- }
-
- echo 'Blogpost ' . htmlentities($slug) . ' detail';
-});
-```
-
-With this example, we can respond to URLs like `/blog`, `/blog/{year}`, `/blog/{year}/{month}`, `/blog/{year}/{month}/{day}`, and `/blog/{year}/{month}/{day}/slug` all with a single route. This is a powerful feature that can significantly reduce the number of routes you need to define, however, it has the downside of being harder to read and understand compared to defining separate routes.
-
-## Successive Optional Sub-patterns
-
-This scary-sounding term means that you can have multiple optional sub-patterns in a row. This is done by nesting the optional sub-patterns inside each other. This is important because it ensures that the optional parts are correctly matched. For example, the example above which is `/blog(/\d+)?(/\d+)?(/\d+)?(/[a-z0-9_-]+)?` can respond to `/blog/somecrazystring` which is not what we want. To fix this, we can nest the optional sub-patterns like this:
-
-```php
-app()->get('/blog(/\d+(/\d+(/\d+(/[a-z0-9_-]+)?)?)?)?', function ($year = null, $month = null, $day = null, $slug = null) {
- // ...
-});
-```
-
-What we've done here is place the sub-patterns inside each other, instead of leaving them next to each other. This ensures that the optional parts are correctly matched and that the route only responds to the correct URLs. Now accessing `/blog/somecrazystring` will not match this route.
-
-## Quantifiers
-
-In the examples above, we used `\d+` to match one or more digits. You can use quantifiers to require a specific number of digits in the URL. For example, you could use `\d{4}` to match exactly 4 digits. This can be useful when you want to ensure that the URL matches a specific format. You can read more about quantifiers in the [PHP documentation](https://www.php.net/manual/en/regexp.reference.repetition.php). Let's update our example to require exactly 4 digits for the year, 2 digits for the month, and 2 digits for the day:
-
-```php
-app()->get('/blog(/\d{4}(/\d{2}(/\d{2}(/[a-z0-9_-]+)?)?)?)?', function ($year = null, $month = null, $day = null, $slug = null) {
- // ...
-});
-```
-
-This ensures that the route only responds to URLs like `/blog/2021/01/01/slug` and not `/blog/17819090091/01/01/slug`.
diff --git a/src/docs/security/anchor.md b/src/docs/security/anchor.md
index 13fec693..731210f5 100644
--- a/src/docs/security/anchor.md
+++ b/src/docs/security/anchor.md
@@ -10,7 +10,7 @@ These attacks happen when attackers pass executable scripts into your applicatio
Anchor prevents this by automagically cleaning up all data that comes into your application. So even if a malicious script is passed into your app, your application will treat it like text instead of a script that should be executed. Pretty cool right?
-Unfortunately, this only works with Leaf functions like `request()`, `response()`, `session()`, etc. If you're using PHP's `$_POST`, `$_GET`, `$_REQUEST`, etc., you will need to sanitize your data manually.
+Unfortunately, this only works with Leaf functions like `request()` and `session()`. If you're using PHP's `$_POST`, `$_GET`, `$_REQUEST`, etc., you will need to sanitize your data manually with `Leaf\Anchor::sanitize()`:
```php{7}
sanitize($data);
+$data = Leaf\Anchor::sanitize($data);
echo $data;
```
+Both `request()` and `session()` let you opt out per call when you need the raw value, for example when you're storing markdown that will be escaped at render time:
+
+```php:no-line-numbers
+request()->get('bio', false); // second argument: sanitize?
+session()->get('draft', null, false); // third argument: sanitize?
+```
+
Note that sanitizing is not a replacement for validating your data. Sanitizing only helps prevent XSS attacks, but you should still validate your data to ensure it's what you expect. You should always keep in mind that user input is evil and should never be trusted π
## Data Validation
@@ -36,7 +43,7 @@ Leaf provides a [form module](/docs/data/validation) that helps you easily valid
CSRF attacks happen when an attacker tricks a user into performing actions they didn't intend to. This is usually done by sending a malicious link to the user, which when clicked, performs an action on the user's behalf.
-Anchor comes with a CSRF helper that handles all the CSRF protection for you. This helper generates a CSRF token for each request and validates it on the server side. This way, you can be sure that the request is coming from your app and not from an attacker. This is not enabled by default, so you will need to enable it in your app.
+Anchor comes with a CSRF helper that handles all the CSRF protection for you. It issues a token for the session and validates it on every state-changing request, so you can be sure the request came from your app and not from somewhere else. Tokens can also be rotated on every request if you want them single-use. This is not enabled by default, so you will need to enable it in your app.
You can [read the CSRF docs](/docs/security/csrf) to learn more about how to use the CSRF helper.
@@ -44,9 +51,9 @@ You can [read the CSRF docs](/docs/security/csrf) to learn more about how to use
SQL Injection attacks happen when an attacker passes SQL queries into your application through input fields. These queries are then executed and can perform any action the attacker needs. This is a very dangerous attack as it can lead to data loss, data theft, and even data corruption.
-Anchor integrates with Leaf DB and automatically takes care of all the necessary escaping for you. This way, you can be sure that all data passed into your database is safe and secure. This is enabled by default, so you don't need to worry about it.
+[Leaf DB](/docs/database/) runs your queries as prepared statements: the values you pass are sent to the database separately from the SQL, so they are never parsed as part of the query. That covers the query builder and models, and it's on by default with nothing to configure.
-Just keep in mind that this only works with Leaf DB. If you're using another database library, you will need to handle parameter binding and escaping yourself.
+Just keep in mind that this only applies to queries you build through Leaf DB. Raw SQL you assemble yourself, and any other database library, still need their own parameter binding.
## CORS Protection
@@ -54,42 +61,178 @@ Cross-Origin Resource Sharing (CORS) is a security feature that allows you to co
Leaf provides a [CORS module](/docs/http/cors) that helps you easily set up CORS protection for your app.
-
+A few protections switch on by themselves once your app runs with `APP_ENV=production`:
+
+- **Error output is hidden.** Visitors get a clean error page instead of a stack trace with your file paths and variables in it. The full report still reaches your logs and any reporting service you attach. See [error handling](/docs/routing/error-handling#disabling-error-reporting).
+- **Session cookies are marked secure over HTTPS.** Leaf checks the connection at boot and sets the flag for you, so session cookies aren't sent over plain HTTP. Set `session.cookie.secure` yourself if you need to override that.
+- **Secrets are stripped from crash reports.** Passwords, tokens, cookies, card numbers and similar values are masked when the report is built, before anything renders or gets sent anywhere.
-
+```
+
+The middleware runs before every route, so each response carries the policy without the route knowing about it. If a malicious `
+
+
+
+
Form security
+
Protect state-changing requests with one module.
+
Leaf CSRF generates, stores, renders, and verifies tokens so forms and frontend requests can stay protected without repetitive wiring.
+
+
+
+
app()->csrf();
+
<form method="POST">
+
@csrf
+
</form>
+
+
+
Using Leaf MVC?
+
MVC has a dedicated CSRF guide for app config, forms, and controllers.
+
-A CSRF (Cross-Site Request Forgery) attack tricks a user into performing unwanted actions on your website without their knowledge. This can be done by sending a request to your website from another website the user is logged into. To prevent this, Leaf provides a powerful CSRF protection module that handles all the funny business for you.
+A CSRF (Cross-Site Request Forgery) attack tricks a user into performing unwanted actions on your website without their knowledge. This can be done by sending a request to your website from another website the user is logged into.
::: details How does CSRF work?
@@ -35,34 +53,6 @@ If you're not familiar with CSRF attacks, this amazing explanation from the Lara
## Setting Up
-
-
-
-
- Using Leaf MVC?
-
-
- We've crafted a specialized guide for using CSRF in Leaf MVC. While it's similar to the base usage in Leaf, it's more detailed and tailored for Leaf MVC.
-
-
-
-
-
-
-
You can install the CSRF module through the Leaf CLI or with composer.
::: code-group
@@ -138,9 +128,45 @@ fetch('/submit', {
This will send a POST request to `/submit` with the CSRF token in the `X-CSRF-Token` header. The CSRF module will automatically verify the token and allow the request to go through if the token is valid.
+## Single-page applications
+
+If you're building an SPA, you usually don't need to pass the token around manually. When CSRF protection is enabled, Leaf sets a JavaScript-readable `XSRF-TOKEN` cookie (with `SameSite=Lax`, and marked secure on https). Clients like Axios and Inertia read this cookie automatically and echo it back as an `X-XSRF-TOKEN` header on every request, which Leaf accepts during verification. In practice, that means an Axios-based frontend gets CSRF protection with zero extra setup.
+
+If you'd rather not have the cookie set, you can turn it off:
+
+```php
+app()->csrf([
+ 'cookie' => false,
+]);
+```
+
+## Rotating tokens
+
+By default, Leaf generates one CSRF token per session and keeps it for the lifetime of that session. This plays nicely with multiple open tabs, since every tab shares the same valid token.
+
+If you want stricter protection, you can make tokens single-use. With rotation on, every verified request throws away the used token and issues a fresh one:
+
+```php
+app()->csrf([
+ 'rotate' => true,
+]);
+```
+
+The trade-off: if a user has a form open in one tab and submits something in another, the first tab's token becomes stale and its submission will fail. Rotation is stricter, but per-session tokens are friendlier for multi-tab apps.
+
+Separately from rotation, you can issue a fresh token yourself with `regenerate()`. We recommend doing this when a user logs in, so the token from the anonymous session doesn't carry over:
+
+```php
+csrf()->regenerate(); // returns the new token
+```
+
## Displaying the generated token
-The CSRF module also provides a `token()` method that returns the CSRF token. You can use this method to display the token in your views or to send the token to your frontend. Be careful not to expose the token to the public, as it can be used to bypass CSRF protection.
+The CSRF module also provides a `token()` method that returns the CSRF token. You can use this method to display the token in your views or to send the token to your frontend.
+
+::: warning Keep your token private
+Be careful not to expose the token to the public, as it can be used to bypass CSRF protection.
+:::
```php:no-line-numbers
$csrfToken = csrf()->token();
@@ -170,29 +196,37 @@ app()->csrf([
]);
```
-::: tip Test Mode
-Leaf automatically disables CSRF protection in test mode. This is to make it easier to test your app without having to worry about CSRF tokens. If you want to test CSRF protection, you can disable test mode by setting the `APP_ENV` environment variable to `production`.
-:::
-
-## Updating the Encryption Secret
-
-Leaf uses a default secret key to encrypt the CSRF token. It is paired together with a random hash to create a unique token for your app. If you want to change the secret key, you can do so by passing a `secret` key to the `csrf()` method.
+Routes with parameters work too. An entry like `/webhooks/{service}` will match any request path in that shape, so `/webhooks/stripe` and `/webhooks/paystack` are both excluded.
```php
app()->csrf([
- 'secret' => 'my-new-secret-key',
+ 'except' => ['/webhooks/{service}'],
]);
```
-It is not required to change the secret key, but it is recommended to do so if you want to add an extra layer of security to your app.
+::: tip Test Mode
+Leaf automatically disables CSRF protection in test mode. This is to make it easier to test your app without having to worry about CSRF tokens. If you want to test CSRF protection, you can disable test mode by setting the `APP_ENV` environment variable to `production`.
+:::
+
+## The Encryption Secret
-If you have an environment file, you can set the secret key there.
+Leaf pairs a secret key with a random hash to create a unique token for your app. Out of the box, Leaf derives this secret from your `APP_KEY`, so every app automatically gets its own secret without any setup. The derived value is mixed with a fixed context string, so it is never your raw app key.
+
+If you want to use your own secret instead, you can set it in your environment file:
```txt:no-line-numbers
X_CSRF_SECRET=my-new-secret-key
```
-
+Or pass it directly to the `csrf()` method:
+
+```php
+app()->csrf([
+ 'secret' => 'my-new-secret-key',
+]);
+```
+
+A secret passed to `csrf()` in code always wins over the environment, and the environment wins over the derived default. If none of the three exist, Leaf throws an error at startup instead of running CSRF protection without a real secret, and the error tells you exactly how to fix it: generate an `APP_KEY` with `php leaf key:generate`, set `X_CSRF_SECRET`, or pass a `secret` to `csrf()`.
## Handling CSRF Failures
diff --git a/src/docs/security/csrf/mvc.md b/src/docs/security/csrf/mvc.md
index 9cfd5453..f3a06122 100644
--- a/src/docs/security/csrf/mvc.md
+++ b/src/docs/security/csrf/mvc.md
@@ -7,12 +7,25 @@ prev: false
-
-
-A CSRF (Cross-Site Request Forgery) attack tricks a user into performing unwanted actions on your website without their knowledge. This can be done by sending a request to your website from another website the user is logged into. To prevent this, Leaf provides a powerful CSRF protection module that handles all the funny business for you.
+
+
+
+
MVC form security
+
Protect forms with middleware and a Blade directive.
+
Leaf MVC can install CSRF protection, wire the middleware, and let your views render tokens with @csrf so state-changing requests stay trusted.
+
+
+
+
$ leaf install csrf
+
<form method="POST">
+
@csrf
+
</form>
+
+
+
+
+
+A CSRF (Cross-Site Request Forgery) attack tricks a user into performing unwanted actions on your website without their knowledge. This can be done by sending a request to your website from another website the user is logged into.
::: details How does CSRF work?
@@ -136,9 +149,41 @@ response = requests.post(url, headers=headers, json=data)
This will send a POST request to `/submit` with the CSRF token in the `X-CSRF-Token` header. The CSRF module will automatically verify the token and allow the request to go through if the token is valid.
+## Single-page applications
+
+If your frontend is an SPA, you usually don't need to pass the token around manually. When CSRF protection is enabled, Leaf sets a JavaScript-readable `XSRF-TOKEN` cookie (with `SameSite=Lax`, and marked secure on https). Clients like Axios and Inertia read this cookie automatically and echo it back as an `X-XSRF-TOKEN` header on every request, which Leaf accepts during verification. That means an Axios-based frontend gets CSRF protection with no extra setup.
+
+If you'd rather not have the cookie set, you can turn it off with the `cookie` key in your published config:
+
+```php
+'cookie' => false,
+```
+
+## Rotating tokens
+
+By default, Leaf generates one CSRF token per session and keeps it for the lifetime of that session. This plays nicely with multiple open tabs, since every tab shares the same valid token.
+
+If you want stricter protection, you can make tokens single-use with the `rotate` key in your published config. With rotation on, every verified request throws away the used token and issues a fresh one:
+
+```php
+'rotate' => true,
+```
+
+The trade-off: if a user has a form open in one tab and submits something in another, the first tab's token becomes stale and its submission will fail. Rotation is stricter, but per-session tokens are friendlier for multi-tab apps.
+
+Separately from rotation, you can issue a fresh token yourself with `regenerate()`. We recommend doing this when a user logs in, so the token from the anonymous session doesn't carry over:
+
+```php
+csrf()->regenerate(); // returns the new token
+```
+
## Displaying the generated token
-The CSRF module also provides a `token()` method that returns the CSRF token. You can use this method to display the token in your views or to send the token to your frontend. Be careful not to expose the token to the public, as it can be used to bypass CSRF protection.
+The CSRF module also provides a `token()` method that returns the CSRF token. You can use this method to display the token in your views or to send the token to your frontend.
+
+::: warning Keep your token private
+Be careful not to expose the token to the public, as it can be used to bypass CSRF protection.
+:::
```php:no-line-numbers
$csrfToken = csrf()->token();
@@ -217,6 +262,32 @@ return [
*/
'methods' => ['POST', 'PUT', 'PATCH', 'DELETE'],
+ /*
+ |----------------------------------------------------------------
+ | Rotate tokens
+ |----------------------------------------------------------------
+ |
+ | When set to true, tokens become single-use: every verified
+ | request discards the used token and issues a fresh one. The
+ | default (false) keeps one token per session, which is
+ | friendlier for apps with multiple open tabs.
+ |
+ */
+ 'rotate' => false,
+
+ /*
+ |----------------------------------------------------------------
+ | SPA cookie
+ |----------------------------------------------------------------
+ |
+ | When enabled, Leaf sets a JS-readable XSRF-TOKEN cookie that
+ | clients like Axios echo back as an X-XSRF-TOKEN header, so
+ | SPAs work without manual token handling. Set to false to
+ | disable the cookie.
+ |
+ */
+ 'cookie' => true,
+
/*
|----------------------------------------------------------------
| Configure missing token message
@@ -257,7 +328,7 @@ return [
];
```
-In this file, you can enable or disable CSRF protection, change the secret key, exclude routes from CSRF protection, and configure the allowed HTTP methods. You can also customize the messages shown when the CSRF token is not found or invalid.
+In this file, you can enable or disable CSRF protection, change the secret key, exclude routes from CSRF protection, turn on token rotation, disable the SPA cookie, and configure the allowed HTTP methods. You can also customize the messages shown when the CSRF token is not found or invalid. Route exceptions can include parameters, so `/webhooks/{service}` will match `/webhooks/stripe` and any other path in that shape.
## Handling Failed CSRF Verification
diff --git a/src/docs/seedling/index.md b/src/docs/seedling/index.md
index 32bede6e..71401dc1 100644
--- a/src/docs/seedling/index.md
+++ b/src/docs/seedling/index.md
@@ -1,6 +1,6 @@
# Seedling
-Seedling is a variation of Leaf MVC optimized for building console applications. It provides a lightweight framework that simplifies the development of command-line tools by leveraging the core principles of Leaf MVC while adapting them to the console environment.
+Seedling is a variation of Leaf MVC built for console applications. It takes the ideas behind Leaf MVC and adapts them to the terminal, so you can build command-line tools with the same structure you already know.
It is built on [Leaf Sprout](/docs/mvc/commands), which allows you to create commands using a simple, familiar structure:
@@ -85,17 +85,25 @@ php leaf greet John --greeting Hi
:::
-You can also use this same command structure to run seedling specific tasks, such as database migrations, seeding, and more.
+Installed modules bring their commands along too: add `leafs/schema` and the `db:` commands appear, add `leafs/queue` and `queue:work` shows up, all with no wiring on your part.
This makes it easy to build your application, but if you are distributing it, [check this out](#distribution)
## Writing commands
-All commands are stored in the `app/console` directory, and we've provided a simple example command to get you started. Since Seedling is just like Leaf MVC, you can create commands the same way you would in a Leaf MVC application. You can refer to the [writing commands documentation](/docs/mvc/commands) for more details on how to create and manage your console commands.
+All commands are stored in the `app/console` directory, and we've provided a simple example command to get you started. You can generate new commands from the console:
+
+```bash:no-line-numbers
+php leaf g:command deploy:site
+```
+
+This creates `app/console/DeploySiteCommand.php` with the signature ready to edit. Made a mess? `php leaf d:command deploy:site` deletes it again.
+
+Since Seedling is just like Leaf MVC, commands themselves work the same way they do in a Leaf MVC application: signatures, arguments, options, and interactive prompts via `sprout()->prompt()`. The [writing commands documentation](/docs/mvc/commands) covers all of it.
## Distribution
-If your Seedling application is intended to be distributed as a package or tool, you can set it up to be installed via composer, either in an app or globally. To do this, head over to your `bin` directory and create and rename the file in there to whatever you want your command to be called, for example, `mytool`. This way if your command is installed globally, users can run:
+If your Seedling application is intended to be distributed as a package or tool, you can set it up to be installed via composer, either in an app or globally. Your `bin` directory already contains a file named after your app (Leaf CLI names it for you during `leaf create`). Rename it to whatever you want the command to be called, for example `mytool`. This way if your command is installed globally, users can run:
```bash:no-line-numbers
mytool greet John --greeting Hi
diff --git a/src/docs/swoole.md b/src/docs/swoole.md
index e1b2b37e..bf196644 100644
--- a/src/docs/swoole.md
+++ b/src/docs/swoole.md
@@ -1,95 +1,40 @@
-# Leaf + Swoole
+# Async PHP with Leaf
-Swoole is a high-performance network framework that supercharges PHP, allowing it to handle multiple tasks at the same time (asynchronous programming). Typically, PHP processes tasks one by one, but Swoole lets it manage thousands of tasks simultaneously, making your app faster and more efficient.
+PHP normally handles one request at a time and starts fresh on every one. Async runtimes like [Swoole](https://swoole.com), [ReactPHP](https://reactphp.org), and [OpenSwoole](https://openswoole.com) change that: your app boots once, stays in memory, and handles many requests concurrently. That means faster responses, WebSockets, timers, and background work inside your app process.
-While Swoole is great for building high-performance applications, it can be a bit complex to use. It has an unfamiliar API and requires a lot of boilerplate code to get started.
+::: warning Eien is being rebuilt
-Leaf simplifies this by providing Eien Server: a module that directly integrates with Swoole and allows Leaf to speak Swoole's language. This means you can use Swoole's features directly in Leaf without having to change your codebase or learn a new API.
+[Eien](https://github.com/leafsphp/eien), the module that connected Leaf to Swoole, is being rewritten from the ground up for Leaf 5.
-*Eien is still in active development, so it may have some bugs. Please report any issues you find on the [Eien GitHub repository](https://github.com/leafsphp/eien).*
+The rewrite makes Eien Leaf's async layer in general, with proper support for **Swoole** and other async PHP libraries like **ReactPHP**, behind one shared contract, so switching runtimes doesn't mean rewriting your app.
-## Getting Started
-
-Eien runs on Swoole, so you need to have the swoole extension installed. Here are some resources to help you get started:
-
-- [Swoole Installation docs](https://openswoole.com/docs/get-started/installation)
-- [In case you have errors installing swoole on Mac](https://parsinta.com/articles/setup-php-swoole-in-your-mac-os)
-
-Once you have Swoole installed, you can install Eien using the Leaf CLI:
-
-::: code-group
-
-```bash:no-line-numbers [Leaf CLI]
-leaf install eien
-```
-
-```bash:no-line-numbers [Composer]
-composer require leafs/eien
-```
+Until it ships, Leaf 5 has no built-in async integration. Everything below describes how to run Leaf in a long-running process today, by hand.
:::
-
-
-## Basic Usage
-
-Eien is designed to make your life easier as a developer. For most cases, like speeding up your app or handling basic HTTP features, you wonβt need to change anything after installing it. Once Leaf detects Eien, it automatically configures everything, and your app will run using Swoole without any extra setup needed. Itβs a hassle-free way to boost performance!
-
-Once again, Eien is still in development, so we need your help to test it in production and report any issues you find.
-
-## Drawbacks
-
-While Leaf on it's own has 100% compatibility with PHP and all it's language features, Eien is built on top of Swoole which has some limitations. Here are some things to keep in mind when using Eien:
-
-- Things involving request and responses all MUST be done using Leaf's request and response objects. This is because Eien serves as a bridge between Leaf and Swoole and so it needs to know what's going on in your app. Directly using things like `header()` or `echo` will not work as expected.
-
-- Websockets are not yet fully supported. We're working on this and it should be more stable in the next few releases.
-
-- We can't guarantee that other libraries outside of Leaf will work as expected. Eien is built to work with Leaf and so we can't guarantee that it will work with other libraries.
-
-## Serving Your Application
+## What changed in Leaf 5
-We promised that there would be no API changes, and this also applies to how you serve your application. You can start your application using the Leaf CLI just as you do with regular Leaf apps:
+Earlier versions of Leaf shipped a built-in integration with Eien: the core auto-detected it and exposed WebSocket routes through `app()->ws()`.
-```bash:no-line-numbers
-leaf serve
-```
+Leaf 5 removes that coupling. `app()->ws()` and the automatic Eien detection are no longer part of the core. Long-running server setups now live outside the core, which keeps the framework lean for the majority of apps that run behind PHP-FPM, and gives the async integration room to be rebuilt properly instead of living half-inside the router.
-## Websockets
+If you're upgrading an app that used `app()->ws()`, see the [upgrade guide](/docs/upgrade-guide). For now, those routes need to move to a dedicated WebSocket setup.
-WebSockets are a communication protocol that allows real-time, two-way interaction between your application and your users. Unlike traditional HTTP requests, where the user has to keep making requests to your app for updates, WebSockets create a persistent connection. Once connected, both your application and users can send and receive messages instantly, without needing to refresh or request new data. This is super useful for real-time apps like chat apps, live updates, or multiplayer games!
+## Running Leaf in a long-running process
-Eien allows you to create WebSocket routes in your Leaf app using a familiar syntax. You can create a WebSocket route just like you would create a regular route, and Eien will handle the rest. Here's an example of a simple WebSocket route:
+You can still run Leaf inside Swoole, ReactPHP, or any long-running worker by booting the app yourself. Two things matter:
-```php
-ws('/ws-route', function () {
- response()->json([
- 'message' => 'Hello from websocket'
- ]);
-});
+## What's coming
-app()->run();
-```
+The new Eien aims to give every async runtime the same shape in Leaf: one way to boot your app, one lifecycle for requests, one place for WebSockets, timers, and background tasks, whether you run it on Swoole or ReactPHP.
-In this example, we create a WebSocket route at `/ws-route` that returns a JSON response with a message. You can create as many WebSocket routes as you want, and Eien will handle them all automatically.
+If you're running Leaf on an async runtime today, or you want a specific library supported, we'd genuinely like to hear about it on [GitHub](https://github.com/leafsphp/leaf/discussions) or [Discord](https://discord.gg/Pkrm9NJPE3). Real setups shape what the rewrite prioritises.
-*This page will be updated as Eien is developed further.*
+*This page will be updated as the new integration lands.*
diff --git a/src/docs/upgrade-guide.md b/src/docs/upgrade-guide.md
new file mode 100644
index 00000000..051a817f
--- /dev/null
+++ b/src/docs/upgrade-guide.md
@@ -0,0 +1,394 @@
+---
+title: "Upgrading to Leaf 5"
+next: false
+prev: false
+---
+
+# Upgrading to Leaf 5
+
+This guide covers moving an existing Leaf 4 app to Leaf 5, including Leaf MVC. Most apps upgrade with little or no code changes: Leaf 5 keeps the same core API and functional idiom, and the biggest rewrites happened under the hood. The sections below cover everything that changed, so you can skim the ones that touch your app.
+
+Items marked stop existing code from working. Items marked keep working but produce different results. Everything else is a fix or an addition.
+
+If you are coming from Leaf 3, read the [Leaf 4 notes](#coming-from-leaf-3) at the end first.
+
+## Updating your dependencies
+
+Leaf 5 requires **PHP 8.2 or newer**, the same floor as Laravel 11+ and Symfony 7, and the oldest PHP still receiving security fixes. Check with `php -v` before upgrading.
+
+Then update your Leaf packages to their v5 versions:
+
+::: code-group
+
+```bash:no-line-numbers [Leaf CLI]
+leaf install leaf@5.0
+```
+
+```bash:no-line-numbers [Composer]
+composer require leafs/leaf:^5.0
+```
+
+:::
+
+If you are using Leaf MVC, update `leafs/mvc-core` and your other Leaf modules to their v5-compatible versions as well.
+
+## How much work is this, module by module?
+
+Find your modules below before reading anything else. Most of them are in the first two tables.
+
+### Safe to upgrade, nothing to change
+
+These modules only gained fixes and features. Update the version and move on.
+
+| Module | Worth knowing |
+| :-- | :-- |
+| `leafs/bareui` | Nothing to do. |
+| `leafs/fetch` | Nothing to do. |
+| `leafs/logger` | Nothing to do. |
+| `leafs/redis` | Nothing to do. |
+| `leafs/queue` | Nothing to do. |
+| `leafs/mail` | Nothing to do. The mailer resets between sends now, which fixes recipient build-up in queue workers. |
+| `leafs/lingo` | Nothing to do. The session, header and router strategies now behave as documented. |
+| `leafs/sitemap` | Nothing to do. `lastmod` only appears when you provide one. |
+| `leafs/blade` | No code changes. Requires PHP 8.2 and Illuminate `^11\|^12\|^13`. |
+| `leafs/vite` | No code changes, same floor as Blade. `@leafphp/vite-plugin` is now ESM-only (Vite 5+, Node 18+). |
+
+### Minimal changes: skim one section, likely change nothing
+
+These have a behavior change or two worth checking against your code. Most apps read the linked section, confirm it doesn't apply, and upgrade.
+
+| Module | What to check |
+| :-- | :-- |
+| `leafs/leaf` | Nothing for most apps. Real changes only if you wrote [raw regex routes](#raw-regex-routes-are-no-longer-supported), a [custom error handler](#custom-error-handlers-receive-a-crash-report), or used [Eien / `$app->ws()`](#async-support-is-paused-while-eien-is-rebuilt). |
+| `leafs/http` | [Untyped request bodies parse as JSON, and `Headers::set()` no longer forces a 200](#requests-and-responses). |
+| `leafs/db` | [`config('key', $falsyValue)` now sets, and transactions work on every driver](#database). |
+| `leafs/form` | [Validation results can flip](#validation): falsy values are values, and `email`/`url`/`ip`/`json` are real validators now. |
+| `leafs/csrf` | [A real secret is required](#security-and-sessions), and any app with an `APP_KEY` is covered automatically. Pre-upgrade tokens need one page refresh. |
+| `leafs/cors` | [Origins match exactly, or by regex](#security-and-sessions). Review your `origin` list. |
+| `leafs/session` | [Flash output is no longer HTML-escaped](#security-and-sessions). Remove compensating `html_entity_decode()` calls. |
+| `leafs/cookie` | [Deletion uses your configured path and domain](#security-and-sessions). Set defaults once with `Cookie::setDefaults()`. |
+| `leafs/date` | [Two-argument `tick()` parses *in* the timezone](#timezones-in-tick-now-parse-instead-of-convert). Add `->tz()` for the old conversion behavior. |
+| `leafs/password` | [`spice()` is a real pepper now](#password-spice-is-now-a-real-pepper). Old hashes still verify and rehash forward on login; `Password::MD5` is gone. |
+| `leafs/cache` | [Your configured store is honored, and the default path moved](#other-modules) outside Leaf MVC. |
+| `leafs/s3` | [`visibility` is actually applied](#other-modules). Re-check anything you uploaded as "private" through v4. |
+| `leafs/schema` | [History moved into your database](#schema-history-moved-into-your-database). The first `db:migrate` imports old snapshots automatically. |
+| `leafs/inertia` | [Page props now win over shared props, and version mismatches force reloads](#frontend-packages). |
+| `leafs/auth` | [Subscription semantics changed](#leaf-mvc): newest subscription wins, grace periods keep access, cancellation defaults to period end. |
+| `leafs/alchemy` | [Verbs replace flags](#console-commands-and-tooling), though the old flags still work. Re-run `alchemy init` to refresh your composer scripts. |
+| `leafs/mvc-core` | Bump together with core. Structure, configs and paths are unchanged; custom console commands are the one real rewrite (next table). |
+
+### Real changes: set aside time
+
+| What you're using | What it takes |
+| :-- | :-- |
+| Custom Aloe commands | [Aloe is gone](#console-commands-and-tooling); rewrite each command on Sprout's `$signature` + `handle()`. Small per command, but every command needs it. |
+| Eien / `$app->ws()` | [Removed while Eien is rebuilt](#async-support-is-paused-while-eien-is-rebuilt). No drop-in v5 replacement: stay on v4 or run your own async worker. |
+| Leaf UI | [Sunset](https://ui.leafphp.dev). Existing apps keep running on the published packages; migrate to Blade, scaffolds, or Inertia when ready. |
+| Leaf Devtools | Sunset. [Leaf Crash](/docs/routing/error-handling) ships the debugging and insight experience inside the error engine itself. |
+| Custom `BillingProvider` implementations | Add `resumeSubscription()` and the `$atPeriodEnd` argument on `cancelSubscription()`. Details under [Leaf MVC](#leaf-mvc). |
+
+## The new routing engine
+
+Leaf 5 compiles your routes when they are registered instead of interpreting them with regex on every request. Exact routes are matched instantly from an index, and dynamic routes are bucketed so only relevant candidates are checked. For most apps this is purely a speed upgrade with no code changes: your `{param}` routes work exactly as before.
+
+It also unlocks two new pattern features:
+
+```php
+// optional parameters
+app()->get('/posts/{id?}', function ($id = null) { ... });
+
+// inline constraints
+app()->get('/users/{id:[0-9]+}', function ($id) { ... });
+```
+
+### Raw regex routes are no longer supported
+
+Routes written as raw regular expressions no longer match, because patterns without `{}` placeholders are now treated as literal paths:
+
+```php
+// β no longer works in Leaf 5
+app()->get('/posts(/edit)?', $handler);
+app()->get('/(\d+)', $handler);
+
+// β use named patterns instead
+app()->get('/posts/{action?}', $handler);
+app()->get('/{id:[0-9]+}', $handler);
+```
+
+If any of your routes contain regex syntax like `(...)`, `\d`, or `?` outside of a `{...}` placeholder, rewrite them using named parameters, optional parameters, or inline constraints.
+
+### Route matching order
+
+Exact routes now always win over dynamic ones, regardless of registration order, so `/users/new` matches its own route even if `/users/{id}` was registered first:
+
+```php
+app()->get('/users/{id}', $userHandler); // order no longer matters here
+app()->get('/users/new', $newHandler); // exact match still wins
+```
+
+When two *dynamic* routes overlap, the one registered first wins, so declare more specific dynamic routes before broader ones.
+
+## Custom error handlers receive a crash report
+
+Leaf 5 replaces the whoops-based error page with a new engine, [Leaf Crash](/docs/routing/error-handling). The old `Leaf\Exception\*` classes still ship and still power 404 and maintenance pages, so `app()->set404()` and `app()->setDown()` are unaffected.
+
+What changed is `app()->setErrorHandler()`. Your callback used to be invoked with three whoops arguments; it now receives a single `Leaf\Crash\Report`:
+
+```php
+// Leaf 4
+app()->setErrorHandler(function ($exception, $inspector, $run) { ... });
+
+// Leaf 5
+app()->setErrorHandler(function (\Leaf\Crash\Report $report) {
+ // $report->message, ->exception, ->frames, ->breadcrumbs, ->toArray()
+});
+```
+
+Handlers are also registered once per process now, so calling `app()->config(...)` later no longer silently replaces the handler you set.
+
+::: tip Your error handler may start running
+`setErrorHandler()` only fires when debug output is off. Because debug now follows `APP_ENV` (below), a production app that never reached your handler in v4 will start using it.
+:::
+
+## Debug output follows your environment
+
+In Leaf 5, detailed error pages are tied to your app environment. When `APP_ENV=production`, debug output is off by default and users see a clean error page instead of a stack trace. In development you get the full debug experience with no configuration.
+
+Prefer logging over re-enabling debug output in production. See [Application Env](/docs/config/environment) and [Error Handling](/docs/routing/error-handling) for details.
+
+## Async support is paused while Eien is rebuilt
+
+`$app->ws()`, the `eien.enabled` config key, and the automatic Eien detection inside `app()->run()` are all removed from the core. Eien is being rewritten from the ground up to cover Swoole *and* other async PHP runtimes like ReactPHP behind one contract, and it is not ready yet.
+
+If your v4 app used Eien or `$app->ws()`, there is no drop-in v5 replacement today. Your options are to stay on Leaf 4 until the new Eien lands, or run Leaf inside your own Swoole/ReactPHP worker and handle WebSockets separately. See [Async PHP with Leaf](/docs/swoole) for what that involves and what's coming.
+
+Related: Leaf 5 adds `Leaf\Router::reset()`, which clears all routes, hooks, middleware, and cached request state. Call it between requests if you run Leaf in a long-running process, and between tests if your suite boots the app more than once.
+
+## Leaf UI is sunset
+
+Leaf UI (reactive PHP components) is retired in Leaf 5. Published packages stay on Packagist so existing apps keep running, but the project is archived and receives no updates. See [Sunsetting Leaf UI](https://ui.leafphp.dev) for the reasoning and migration paths (Blade, scaffolds, Inertia + React/Vue/Svelte).
+
+## Schema history moved into your database
+
+Leaf 4 tracked schema file history as snapshots in `storage/database`. Leaf 5 tracks it in a `leaf_schema_history` table inside the database being migrated, so each environment knows what was actually applied to it rather than trusting local files.
+
+The migration is automatic. Your first `leaf db:migrate` imports any existing `storage/database` snapshots into the new table and removes the old directory. Tables with no history at all are adopted from their current schema file instead of being recreated.
+
+Two things to know:
+
+- **Rollbacks no longer rewrite your schema files.** Leaf 4 swapped your `.yml` file for the older snapshot; Leaf 5 changes only the database and history, leaving your files as you wrote them. After a rollback your file is ahead of the database, so run `db:migrate` to re-apply it or edit it to match.
+- **Deploys need to run migrations per environment.** This was already true in practice, but the history is now per-database, so staging and production each build their own record the first time they migrate.
+
+See [Schema files](/docs/database/files) for the full picture.
+
+## Environment reads are cached
+
+`_env()` now parses your environment once and caches it for the rest of the request (this is part of why env reads are dramatically faster in v5). If your code changes environment values at runtime with `putenv()` and expects `_env()` to pick them up, that no longer happens. For that one case, use `_envUncached()`. It has the same signature and value casting as `_env()`, but it reads the environment live on every call and pays the full cost of doing so every time.
+
+```php
+putenv('FEATURE_FLAG=true');
+
+_env('FEATURE_FLAG'); // null, the cache was built before putenv()
+_envUncached('FEATURE_FLAG'); // true
+```
+
+## Timezones in `tick()` now parse instead of convert
+
+`tick('2026-01-15 12:00:00', 'Asia/Tokyo')` now means "noon *as experienced in Tokyo*" (day.js semantics) instead of "parse noon in the server timezone, then convert to Tokyo". If you relied on the old conversion behavior, move the timezone to a `tz()` call:
+
+```php
+tick($date, $timezone); // Leaf 4 converted; Leaf 5 parses IN the timezone
+tick($date)->tz($timezone); // Leaf 5: converts, same as the old behavior
+```
+
+Single-argument `tick()` calls are unaffected. See [working with timezones](/docs/utils/date#working-with-timezones) for the new API (`tz()`, `utc()`, `utcOffset()`).
+
+## Password spice is now a real pepper
+
+`Password::spice()` now keys passwords through HMAC-SHA256 instead of concatenating the spice as text. New hashes use the stronger scheme automatically; hashes created on Leaf 4 still verify through a fallback, and [`Password::needsRehash()`](/docs/data/encryption#password-needsrehash) migrates them forward on login. `Password::ARGON2` now maps to Argon2id (was Argon2i), and the broken `Password::MD5` constant is removed.
+
+## Requests and responses
+
+Most of the work in `leafs/http` fixed things that were quietly wrong. Two are worth checking your code against:
+
+- **Bodies sent without a `Content-Type` header are now parsed as JSON.** In v4 they fell through to a raw one-element array. Clients that post untyped bodies will start receiving parsed, sanitized data.
+- **`Headers::set()` no longer forces a 200 status.** Its fourth argument defaults to `null` instead of `200`, so setting a header no longer resets the response code. If you leaned on that side effect, set the status explicitly. Note `response()->withHeader()` still sets a status (200 by default), so `response()->status(404)->withHeader(...)` remains a 200.
+
+::: details Bugs fixed in requests and responses
+None of these need changes on your side, but behavior differs from v4:
+
+- `request()->params()` with no key returns the whole body instead of fatally erroring
+- Content-type matching handles `; charset=utf-8` and mixed case
+- Form-encoded bodies are parsed with `parse_str()`, so valueless flags, values containing `=`, and nested `a[b]=1` all work
+- `getContentLength()` returns the real length (was always 0), `getPort()` falls back to 80 (was 0)
+- `getFullUrl()` no longer repeats the query string
+- `getIp()` returns the first entry of a forwarded list rather than the whole list
+- `Headers::has()` and `hasHeader()` check header *names*; in v4 they compared against values
+- `response()->download()` without a name emits a valid filename, streams in chunks instead of loading the file into memory, and returns early for missing files
+- `response()->status(null)` is a no-op instead of nulling the status
+- `withFlash([...])` no longer makes a junk extra flash call
+:::
+
+New, non-breaking: `request()->object()` returns the body as objects, `response()->view()`/`render()` take a status code, and downloads support HTTP Range requests for resumable and parallel downloads.
+
+## Database
+
+- **`db()->config('key', $falsyValue)` now sets instead of gets.** v4 gated on `!$value`, so `config('password', '')` or `config('port', 0)` silently returned the current value and stored nothing.
+- **Transactions work on every PDO driver.** v4 issued MySQL-only `START TRANSACTION` text, so `transaction()` on SQLite, Postgres, or SQL Server failed before your callback ever ran.
+- **`unique()` and eager loading run as prepared statements.** Alongside the safety win, values containing quotes and string or UUID foreign keys now work where v4 produced syntax errors.
+- Transaction failures land in `errors()['transaction']` as a string. v4 assigned the exception object to a property that `errors()` never read.
+- `beginTransaction()` no longer clears a half-built query, and `debug()` reports connections under `connections`.
+
+## Validation
+
+`leafs/form` changed in ways that can flip a validation result:
+
+- **Falsy values are values.** `false`, `0`, and `'0'` no longer fail as "required"; only `null`, `''`, and `[]` count as missing. In v4 a `boolean` rule could never validate a false value.
+- **`email`, `url`, `ip`, `ipv4`, `ipv6` and `json` are real validators now**, not regexes. Addresses with modern TLDs (`.photography`, `.info`) start passing, and values like `999.999.999.999` or malformed JSON start failing.
+- **Rule parameters keep their case.** `contains` matches `Foo`; v4 lowercased it. Rule *names* are still case-insensitive.
+- `matchesvalueof` compares against the data being validated rather than the global request, which only changes results for `form()->validate($yourArray, ...)`.
+- `form()->submit()` is deprecated and will be removed next major.
+
+New: custom rule callables receive the full data set as a fourth argument (so cross-field rules are possible), and messages can target one field with `addMessage('password.min', ...)`.
+
+## Security and sessions
+
+- **CSRF tokens use a new format.** Tokens minted before the upgrade won't validate on Leaf 5, so a session holding one needs a single page refresh. There's nothing to configure.
+- **CSRF now requires a real secret.** Leaf 5 resolves the CSRF secret in order: a `secret` passed to `csrf()`, then `X_CSRF_SECRET` from your `.env`, then a secret derived automatically from your `APP_KEY`. If none of the three exist, the app throws at startup instead of running CSRF protection without one. Most apps need to do nothing, since any project with an `APP_KEY` is covered. If you hit the error, run `php leaf key:generate` or set `X_CSRF_SECRET` in your `.env`. The derived secret is mixed with a fixed context string, so it is never your raw app key, and changing your `APP_KEY` invalidates in-flight CSRF tokens (a page refresh mints new ones).
+- **CORS origins are matched exactly, or by regex.** An origin you allow must be written in full, scheme included, and it matches that origin only. For a family of subdomains, pass a regex string instead:
+
+ ```php
+ app()->cors(['origin' => 'https://example.com']);
+ app()->cors(['origin' => '/^https:\/\/(.*\.)?example\.com$/']);
+ ```
+
+ Review your `origin` config while upgrading and make sure each entry is a full origin or a regex.
+- **Flash data is no longer HTML-escaped on the way out.** v4 re-sanitized the flash store on every write and escaped again on read, so flashed form input came back as `Tom & Jerry`. If you compensated with `html_entity_decode()`, remove it.
+- **Cookie deletion uses your configured path and domain.** `unset()` and `delete()` now send the same scope the cookie was set with, so set your defaults once with `Cookie::setDefaults(['path' => '/'])` and both writing and clearing stay consistent.
+- Dot notation works at any depth. v4 truncated `a.b.c` to two levels and warned that nested config could not go deeper.
+
+New: CSRF gains opt-in single-use tokens (`rotate`), `regenerate()`, an automatic `XSRF-TOKEN` cookie plus `X-XSRF-TOKEN` header so SPA clients need no manual plumbing, and a per-app secret derived from your `APP_KEY` with zero configuration.
+
+## Other modules
+
+::: details Mail, cache, storage, localisation, sitemap
+**Mail.** The mailer resets between sends, so recipients, attachments and reply-tos no longer accumulate. In a queue worker, v4 delivered each mail to every previous recipient as well. `cc`/`bcc` accept arrays, `replyTo` works from the mail or from your `connect()` defaults, and sending without `connect()` throws a clear exception instead of a null fatal.
+
+**Cache.** The configured default store is honored; v4 built a file store directly and ignored the rest of your config. Outside Leaf MVC the default path is `storage/framework/cache` under your working directory rather than `/cache` at the filesystem root. Only closures are evaluated lazily, so `cache('key', 600, 'strtolower')` caches the string instead of running the function.
+
+**S3 storage.** The `visibility` option you pass is what gets applied, so private uploads stay private. Worth re-checking the visibility on anything you uploaded through Leaf 4 while you upgrade. `createFile('docs/note.txt')` now writes to that exact path instead of creating a directory named after the file, and URL generation uses the connection's `endpoint`, which fixes URLs for R2, Minio and Spaces. Bucket paths from `withBucket()` also work with `read()`, `write()`, `exists()`, `delete()`, `size()`, `lastModified()` and `mimeType()` now, where Leaf 4 only supported creating and uploading.
+
+**Lingo.** The session strategy remembers the chosen locale (v4 reset it to the default on every request), the header strategy parses `Accept-Language` properly instead of comparing the raw header to filenames, and the router strategy validates the first URL segment against your locales so `/about` is no longer read as a locale. Custom strategies and nested YAML files are new.
+
+**Sitemap.** `lastmod` only appears when you provide one, instead of stamping every URL with the generation time, and dynamic routes are left out unless you map them to real URLs.
+:::
+
+## Frontend packages
+
+`leafs/blade`, `leafs/inertia` and `leafs/vite` now require PHP 8.2 and Illuminate `^11|^12|^13`. An app pinned to Laravel 8 or 10 components cannot install them. Blade and Vite have no behavior changes of their own.
+
+Inertia changed more:
+
+- Asset-version mismatches return 409 with `X-Inertia-Location`, so stale clients reload. Apps that never set a version can see forced reloads when the computed version changes.
+- Page props now win over shared props of the same name. v4 merged the other way around.
+- `setOmittedProps()` works properly now, which means props you believed were shared may genuinely disappear.
+- `Inertia::share()` only resolves closures, so sharing `'time'` or `[$obj, 'method']` passes the value through unresolved.
+- SSR needs both `head` and `body` in the response, and no longer depends on `leafs/fetch`.
+
+New: `optional()`, `defer()`, `always()`, `merge()`, `deepMerge()`, `encryptHistory()`, `clearHistory()`, and `location()`. `Inertia::lazy()` still works but is deprecated.
+
+If you use `@leafphp/vite-plugin`, it is now ESM-only and needs Vite 5+ and Node 18+.
+
+## Leaf MVC
+
+Leaf MVC 5 keeps the same app structure: `app/`, the `leaf` console file, `public/index.php`, `AppPaths()`, `MvcConfig()`, `StoragePath()`, and every published config file are unchanged. Upgrading is mostly bumping versions.
+
+- Update `leafs/mvc-core`, `leafs/leaf`, `leafs/blade`, `leafs/logger` and `leafs/schema` together. The schema jump is the large one, from the `0.1.x` line.
+- **`leaf serve` no longer needs Node.** It runs Vite, Redis and the queue worker as child processes with prefixed output instead of shelling out to `npx concurrently`, and it watches `.env` itself. Vite only starts when a `package.json` exists, and `--clean` now skips Redis and the queue worker too.
+- `lib/` is autoloaded in console commands as well as web requests, and the console boots without a view engine installed, which matters for API-only apps.
+- `route()` accepts named parameter arrays properly. v4 tried a `str_replace` with an array and produced an array-to-string error.
+- `scaffold:shadcn` now fails with a clear message outside React apps, and `scaffold:auth` / `scaffold:mail` fail loudly when a composer install fails rather than continuing silently.
+
+If you use `leafs/auth` subscriptions, four behavior changes matter:
+
+- `subscription()` returns the newest subscription rather than the oldest, so a resubscribed user no longer sees their cancelled row.
+- `hasActiveSubscription()` returns true during the cancellation grace period, so access gating that assumed "cancelled means no access" changes meaning.
+- `cancelSubscription()` cancels at period end by default. Pass `false` for the old immediate behavior.
+- Custom `BillingProvider` implementations must add `resumeSubscription()` and accept the new `$atPeriodEnd` argument on `cancelSubscription()`.
+
+[Roles and permissions](/docs/auth/permissions) are out of beta. One behavior change: `$user->assign()` returns `false` and raises an error when given a role that was never registered with `createRoles()`, where it previously returned `true` without granting anything.
+
+## Console commands and tooling
+
+**Aloe is gone.** Sprout replaces it, and Symfony Console is no longer in the stack, so custom commands need rewriting:
+
+```php
+// Leaf 4 (Aloe)
+use Aloe\Command;
+
+class GreetCommand extends Command
+{
+ protected static $defaultName = 'greet';
+
+ protected function config()
+ {
+ $this->setArgument('name', 'required');
+ }
+}
+
+// Leaf 5 (Sprout)
+use Leaf\Sprout\Command;
+
+class GreetCommand extends Command
+{
+ protected $signature = 'greet {name} {--loud}';
+
+ protected function handle(): int
+ {
+ $this->writeln("Hello {$this->argument('name')}");
+
+ return 0;
+ }
+}
+```
+
+`handle()` must return an int. The Aloe I/O helpers (`ask()`, `choice()`, `confirm()`, `secret()`, `table()` and friends) are replaced by `sprout()->prompt([...])` and `sprout()->confirm()`, and only the `error`, `info`, `comment`, `question`, `b`, `u`, `i` and `reset` style tags are supported, so `` prints literally.
+
+**Alchemy uses verbs instead of flags**, and `lint` changed meaning:
+
+| Leaf 4 | Leaf 5 |
+| :-- | :-- |
+| `alchemy setup --test` | `alchemy test` |
+| `alchemy setup --lint` (rewrote files) | `alchemy lint` (checks, fails on violations) or `alchemy fmt` (rewrites) |
+| `alchemy setup --actions` | `alchemy ci` |
+
+The old flags still work, and `alchemy setup` prints a deprecation notice. Generated config now lives in `.alchemy/` instead of your project root, and CI workflow files carrying alchemy's generated header are refreshed rather than skipped. Run `alchemy init` to regenerate your composer scripts.
+
+**Exit codes are real now** across the CLI and alchemy. Several v4 commands returned `(int) $bool`, which meant 1 on success and 0 on failure, so pipelines that looked green may legitimately start failing.
+
+CLI changes worth knowing: `leaf create --basic` is now `--lite` (the old flag still works), `leaf view:install --tailwind|--vite|--vue` were broken in v4 MVC apps and now install what you asked for, and `leaf deploy` actually deploys rather than only writing config files.
+
+## New in Leaf 5
+
+Not required for upgrading, but worth adopting once you're on v5:
+
+- **[AI-ready projects](/docs/ai)**: `.leaf/CONTEXT.md` gives agents shared project memory, and `leaf context` prints a handoff for external assistants.
+- **[`leaf up`](/docs/cli/)**: scale a lite app into a full Leaf MVC structure when your product needs it.
+- **[Multiple database connections](/docs/database/)**: `db()->addConnections([...])` and `db('analytics')->select(...)`.
+- **[Scaffolds](/docs/mvc/scaffolds)**: auth, landing pages, subscriptions, waitlists, AI chat, blogs, contact forms and legal pages as editable starting points, each with Blade, React, Vue and Svelte variants.
+- **[Real faker support in seeds](/docs/database/files#seeding-your-database)**: schema file seeds now take full faker expressions (`'@faker.unique.safeEmail'`, `'@faker.numberBetween(1, 5)'`) instead of the handful of primitive tokens v4 understood. The old tokens still work.
+- **[Console apps with Seedling](/docs/seedling/)**: the Leaf MVC experience for CLI applications.
+- **[Named route groups](/docs/routing/route-groups#named-groups)**: group names cascade (`admin.users.index`), and resource routes name themselves.
+- **Group middleware runs on dynamic routes**: a long-standing bug where middleware (including `auth.required`) was silently skipped for `/{id}`-style routes inside groups whenever a global middleware existed is fixed. If routes suddenly enforce auth they previously skipped, that's the fix working.
+- **Smarter base path detection**: subfolder detection only strips URL prefixes when requests actually live under your script's folder, so `php -S` and CLI runs no longer 404 or lose URI segments.
+
+## Coming from Leaf 3
+
+If you are jumping from Leaf 3 straight to Leaf 5, the changes above still apply, plus the Leaf 4-era changes you skipped:
+
+- Functional mode (`app()`, `request()`, `response()`, `auth()`, `db()`) is the default idiom throughout the docs.
+- Sessions are opt-in in the core; enable them when you need them.
+- Session guards were deprecated in Leaf 4 (and removed in v5), so move to auth middleware as shown above.
+- The Leaf MVC console and directory structure were streamlined; if you have a Leaf 3 MVC app, the smoothest path is creating a fresh Leaf 5 MVC app and moving your controllers, models, and views over.
+
+If you hit something this guide doesn't cover, [open an issue](https://github.com/leafsphp/leaf/issues). We're actively filling in migration gaps.
diff --git a/src/docs/utils/billing.md b/src/docs/utils/billing.md
index 821b693d..1f6c3366 100644
--- a/src/docs/utils/billing.md
+++ b/src/docs/utils/billing.md
@@ -1,6 +1,6 @@
# Payments/Billing
-Leaf MVCβs billing system helps makers move faster by handling payments and subscriptions out of the box. With built-in Stripe/PayStack supportβand more providers like Lemonsqueezy coming soonβyou can set up one-time payments or recurring subscriptions in just a few minutes. That means less time worrying about billing and more time building.
+Leaf MVCβs billing system handles payments and subscriptions out of the box. With built-in Stripe/PayStack support (and more providers like Lemonsqueezy coming soon), you can set up one-time payments or recurring subscriptions in a few minutes and get back to building your app.
## Setting up
@@ -10,14 +10,14 @@ To get started, create an account on the payment provider you want to use, and g
::: code-group
-```env:no-line-numbers [Stripe]
+```txt:no-line-numbers [Stripe]
BILLING_PROVIDER=stripe
STRIPE_API_KEY=sk_test_XXXX
STRIPE_PUBLISHABLE_KEY=pk_test_XXXX
STRIPE_WEBHOOK_SECRET=whsec_XXXX # only if you are using webhooks
```
-```env:no-line-numbers [PayStack]
+```txt:no-line-numbers [PayStack]
BILLING_PROVIDER=paystack
PAYSTACK_API_KEY=sk_test_XXXXX
PAYSTACK_PUBLISHABLE_KEY=pk_text_XXXX
@@ -46,7 +46,7 @@ You only need to install the module for the billing provider you intend to use,
## Billing on-the-fly
-Billing on-the-fly is the fastest way to charge customersβideal for one-time payments, donations, or services. Just generate a payment link with Leaf Billing, and weβll handle the rest. You can do this using the `billing()` helper in your controller.
+Billing on-the-fly is the fastest way to charge customers, ideal for one-time payments and donations. Just generate a payment link with Leaf Billing, and weβll handle the rest. You can do this using the `billing()` helper in your controller.
::: code-group
@@ -68,7 +68,7 @@ public function handleCartPurchase($cartId) {
$cart->payment_session = $session->id();
$cart->save();
- response()->redirect($session->url());
+ return response()->redirect($session->url());
}
```
@@ -93,13 +93,13 @@ public function handleCartPurchase($cartId) {
$cart->payment_session = $session->id();
$cart->save();
- response()->redirect($session->url());
+ return response()->redirect($session->url());
}
```
:::
-Leaf takes care of the entire payment session for youβautomatically tracking the user (if available), any metadata you provide, and the payment status, keeping your code clean and focused on your app.
+Leaf takes care of the entire payment session for you: it automatically tracks the user (if available), any metadata you provide, and the payment status, so your code stays focused on your app.
This is a list of the parameters you can pass to the `charge()` method:
@@ -170,9 +170,9 @@ class CallbacksController extends Controller
`billing()->callback()` parses and validates the callback, returning a BillingSession with full payment details. Stripe and PayStack send back different data, but Leaf normalizes it for you, so you can handle the payment result in one place.
-## Billing with subscriptions
+## Billing with subscriptions
-Unlike one-time payments, subscriptions require a more structured setupβbut Leaf Billing makes it effortless. Just run the `scaffold:subscriptions` command to instantly generate everything you need: billing config, controllers, routes, and views. You'll be up and running with subscriptions in minutes.
+Unlike one-time payments, subscriptions need a more structured setup, but Leaf Billing does most of it for you. Run the `scaffold:subscriptions` command to generate everything you need: billing config, controllers, routes, and views.
```bash:no-line-numbers
leaf scaffold:subscriptions
@@ -247,7 +247,7 @@ You can use the following keys:
| `discount` | The discount percentage | `true` |
| `features` | An array of features for the tier | `true` |
-You can set different prices for various durationsβ`monthly`, `yearly`, `quarterly`, `weekly`, or even `daily` in the format `price.monthly`, `price.yearly`, etc.
+You can set different prices for various durations (`monthly`, `yearly`, `quarterly`, `weekly`, or even `daily`) in the format `price.monthly`, `price.yearly`, etc.
Once you've set up your billing tiers like the example above, you just need to publish them on Stripe. You can do that by running the following command:
@@ -259,7 +259,7 @@ That's it! We can now let users subscribe to our plans.
## Displaying your plans
-The `scaffold:subscriptions` command also generates a pricing component tailored to your chosen view engineβBlade, React, Vue, or Svelte. You can display your plans with just one line of code. The component is fully customizable, so you can tweak the design to match your appβs look and feel seamlessly.
+The `scaffold:subscriptions` command also generates a pricing component tailored to your chosen view engine: Blade, React, Vue, or Svelte. You can display your plans with one line of code, and the component is fully customizable, so you can tweak the design to match your appβs look and feel.
::: code-group
@@ -299,11 +299,11 @@ import Pricing from '@/components/billing/pricing.svelte';
Clicking the "Subscribe" button takes users to the billing providerβs checkout page, where they can enter their payment details. After completing the payment, theyβll be redirected back to your application's callback automatically.
-Leaf handles most of the subscription logic out of the box, but since every app is different, you may need to tweak the generated filesβespecially the webhook handlersβto fit your specific use case.
+Leaf handles most of the subscription logic out of the box, but since every app is different, you may need to tweak the generated files (especially the webhook handlers) to fit your specific use case.
## Billing Events/Webhooks
-Once youβve charged a customerβespecially for a subscriptionβyouβll want to track their payment status. The best way to do this is through webhooks. When you run the `scaffold:subscriptions` command, Leaf Billing automatically generates a webhook controller that listens for events from your billing provider and handles them for you.
+Once youβve charged a customer, especially for a subscription, youβll want to track their payment status. The best way to do this is through webhooks. When you run the `scaffold:subscriptions` command, Leaf Billing automatically generates a webhook controller that listens for events from your billing provider and handles them for you.
```php:no-line-numbers [WebhooksController.php]
webhook();
/**
+ * $event->id() - the provider's unique event id (store it to skip redelivered events)
* $event->type() - to get the event type
* $event->is() - to check if the event is a specific type
* $event->tier() - to get the subscription tier (if available)
* $event->subscription() - to get the current subscription (if available)
* $event->user() - to get the current user (returns auth()->user() if available)
* $event->previousSubscriptionTier() - to get the previous subscription tier (if available)
- * $event->cancelSubscription() - to cancel the subscription in webhook request (if available)
* $event->activateSubscription() - to activate the new subscription in webhook (if available)
+ * $event->renewSubscription() - to extend the subscription after a successful renewal payment
+ * $event->markSubscriptionPastDue() - to flag the subscription when a renewal payment fails
+ * $event->cancelSubscription() - to cancel the subscription in webhook request (if available)
*/
if ($event->is('invoice.payment_succeeded')) {
// Payment was successful
if ($event->data()['object']['billing_reason'] === 'subscription_cycle') {
- // Subscription renewed/charged after trial/cycle
- // β Give access to your service
+ // Subscription renewed: push end_date a period forward and
+ // clear any past_due state from failed earlier attempts
+ $event->renewSubscription();
}
// Other payment succeeded events
@@ -349,15 +353,26 @@ class WebhooksController extends Controller
return;
}
+ if ($event->is('invoice.payment_failed')) {
+ // Renewal payment failed: user enters dunning. Stripe retries the
+ // charge; invoice.payment_succeeded will clear this when it recovers
+ $event->markSubscriptionPastDue();
+
+ // π§ Maybe email the user to update their card?
+ // billing()->portal() gives them a link to do exactly that
+
+ return;
+ }
+
if ($event->is('customer.subscription.updated')) {
if ($event->activateSubscription()) {
- response()->json([
+ return response()->json([
'status' => 'success',
]);
} else {
// Subscription was not activated
// β Retry or handle manually
- response()->json([
+ return response()->json([
'status' => 'failed',
], 500);
}
@@ -367,13 +382,13 @@ class WebhooksController extends Controller
if ($event->is('customer.subscription.deleted')) {
if ($event->cancelSubscription()) {
- response()->json([
+ return response()->json([
'status' => 'success',
]);
} else {
// Subscription was not cancelled
// β Retry or handle manually
- response()->json([
+ return response()->json([
'status' => 'failed',
], 500);
}
@@ -406,18 +421,21 @@ class WebhooksController extends Controller
Since webhooks are stateless, you can't use the `session()` or `auth()` helpers to retrieve the user who made the payment. This is a common issue with webhooks, as they are designed to be stateless and don't have access to the session or authentication data. However, Leaf Billing automatically parses the webhook payload and provides you with a `BillingEvent` instance, which gives you access to the user who made the payment, the subscription, and all other relevant details.
-| Method | Description |
-| ---------------------------- | ---------------------------------------------------------- |
-| `type()` | Get the event type |
-| `is()` | Check if the event is a specific type |
-| `tier()` | Get the subscription tier (if available) |
-| `subscription()` | Get the current subscription (if available) |
-| `user()` | Get the current user (returns auth()->user() if available) |
-| `previousSubscriptionTier()` | Get the previous subscription tier (if available) |
-| `cancelSubscription()` | Cancel the subscription in webhook request (if available) |
-| `activateSubscription()` | Activate the new subscription in webhook (if available) |
-| `data()` | Get the raw event data |
-| `metadata()` | Get the metadata from the event (if available) |
+| Method | Description |
+| ---------------------------- | -------------------------------------------------------------------------------------------------------- |
+| `id()` | The provider's unique event id. Store handled ids to make your webhook idempotent against redeliveries |
+| `type()` | Get the event type |
+| `is()` | Check if the event is a specific type |
+| `tier()` | Get the subscription tier (if available) |
+| `subscription()` | Get the current subscription (resolved straight from the database, no auth context needed) |
+| `user()` | Get the current user (returns auth()->user() if available) |
+| `previousSubscriptionTier()` | Get the previous subscription tier (if available) |
+| `activateSubscription()` | Activate the new subscription in webhook (if available) |
+| `renewSubscription()` | Extend the subscription one billing period after a successful renewal payment (also clears past_due) |
+| `markSubscriptionPastDue()` | Flag the subscription as past due when a renewal payment fails (dunning) |
+| `cancelSubscription()` | Cancel the subscription, keeping access until the paid-for period ends; pass `false` to revoke instantly |
+| `data()` | Get the raw event data |
+| `metadata()` | Get the metadata from the event (if available) |
For more information on billing events, you can check the [Stripe](https://stripe.com/docs/api/events/types) and [PayStack](https://paystack.com/docs/payments/webhooks/#types-of-events) documentation.
@@ -435,10 +453,6 @@ Keep the process open and then perform an action in your application that trigge
You can check the user's billing status directly from the user object, either from your controller or your view. The user object is automatically injected into your views, so you can easily check the user's billing status in your views as well. The most basic use-cases are to check if the user is subscribed to a plan or if the user is on a trial period.
-
-
::: code-group
```blade:no-line-numbers [Blade]
@@ -446,7 +460,7 @@ You can check the user's billing status directly from the user object, either fr
@endif
```
@@ -520,12 +534,12 @@ In the `config/billing.php` file, you can set a `trialDays` key for each tier. T
];
```
-You can set the trial period for each tier, and the user will be billed after the trial period is over. In your code, you can check if the user is in the trial period by checking the `isOnTrial()` method on the billing instance.
+You can set the trial period for each tier, and the user will be billed after the trial period is over. In your code, you can check if the user is in the trial period by checking the `onTrial()` method on the user object.
::: code-group
```blade:no-line-numbers [Blade]
-@if (auth()->user()->isOnTrial())
+@if (auth()->user()->onTrial())
You are on a trial period
@endif
```
@@ -594,27 +608,99 @@ class User extends Model {
This way, you can easily check the user's subscription status, plan, and other billing information directly from the user model or any other model you add the `HasBilling` trait to. -->
+## Subscription status on the user object
+
+Beyond the basic checks, the user object understands the full subscription lifecycle:
+
+| Method | Description |
+| ---------------------------- | ----------------------------------------------------------------------------------------------- |
+| `subscription()` | The user's latest subscription with its tier attached |
+| `hasActiveSubscription()` | True for active and trialing users, and for cancelled users still inside their paid-for period |
+| `onTrial()` | True while the user's trial is running |
+| `onGracePeriod()` | True when the user cancelled but still has access until the period they paid for ends |
+| `hasPastDueSubscription()` | True when a renewal payment failed and the subscription is in dunning |
+| `cancelSubscription()` | Cancel at period end by default; pass `false` to cancel immediately |
+| `resumeSubscription()` | Undo a period-end cancellation while the grace period is still running |
+
+## Cancelling and resuming subscriptions
+
+When a user cancels, you almost never want to cut access on the spot, because they paid for the current period. Leaf cancels at the end of the billing period by default:
+
+```php
+auth()->user()->cancelSubscription(); // keeps access until the period ends
+
+auth()->user()->cancelSubscription(false); // cancels and revokes immediately
+```
+
+Between cancelling and the period actually ending, the user is on a *grace period*: `hasActiveSubscription()` stays true and `onGracePeriod()` tells you they're on the way out. That's a good moment for a "changed your mind?" banner:
+
+```php
+if (auth()->user()->onGracePeriod()) {
+ // show a resume button instead of the subscribe button
+}
+```
+
+If they do change their mind before the period runs out, resume picks the subscription right back up with no new checkout:
+
+```php
+auth()->user()->resumeSubscription();
+```
+
+::: info Paystack cancellations
+Paystack always cancels at period end: disabling a subscription stops future renewals, but access naturally runs to the end of the paid period. Passing `false` only affects your local records.
+:::
+
+## Switching plans
+
+Upgrading or downgrading a subscribed user doesn't need a new checkout. `changeSubscription()` swaps the plan on the provider using the payment method already on file:
+
+```php
+billing()->changeSubscription([
+ 'id' => $tierId, // or 'name' => 'Pro'
+]);
+```
+
+On Stripe the swap happens in place with proration, so the user is credited for unused time on the old plan. On Paystack (which has no in-place plan swaps) the old subscription is disabled and a new one is created on the new plan using the saved card authorization.
+
+## The customer portal
+
+Card expired? User wants their invoices? Instead of building billing management UI, you can send users to your provider's hosted portal:
+
+```php
+app()->get('/billing/portal', fn () => response()->redirect(
+ billing()->portal('/dashboard') // where to return the user afterwards
+));
+```
+
+On Stripe this opens the [Billing Portal](https://docs.stripe.com/customer-management) (update card, view invoices, cancel); on Paystack it opens the subscription management page (update card, cancel). `portal()` returns `null` when there's nothing to manage, e.g. the user has no billing history yet.
+
+## Failed renewal payments
+
+When a renewal charge fails, your webhook marks the subscription past due (`invoice.payment_failed` in the generated controller does this already). From there:
+
+- `hasActiveSubscription()` returns false, so gated content locks automatically
+- `hasPastDueSubscription()` lets you show a "payment failed, update your card" notice with a `billing()->portal()` link
+- Your provider retries the charge on its own schedule; when it succeeds, `invoice.payment_succeeded` fires and `$event->renewSubscription()` restores access. Nothing else to do
+
## Billing Middleware
Leaf billing comes with a middleware that you can use to protect your routes based on specific conditions. This is a list of the billing middleware available:
| Middleware | Description |
-| ---------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------- |
+| ---------------------------------- | --------------------------------------------------------------------- |
| `billing.subscribed` | Protect a route to only allow subscribed users |
| `billing.subscribed:plan-name` | Protect a route to only allow users subscribed to a specific plan |
| `billing.not-subscribed` | Protect a route to only allow users who aren't subscribed |
| `billing.not-subscribed:plan-name` | Protect a route to only allow users not subscribed to a specific plan |
-| |
+| `billing.trial` | Protect a route to only allow users on a trial period |
+| `billing.not-trial` | Protect a route to only allow users not on a trial period |
You can use these middlewares in your routes like this:
```php [_some-route.php]
app()->get('/protected', [
'middleware' => 'billing.subscribed',
- function() {
- return 'You are subscribed';
- }
+ fn () => 'You are subscribed'
]);
app()->get('/protected', [
@@ -626,9 +712,7 @@ app()->get('/protected', [
If you want to customize what the middleware does if the user is not allowed to access the route, you can do that by calling the `billing()->middleware()` method in your `app/routes/index.php` file. This method accepts a callback that will be called if the user is not allowed to access the route.
```php:no-line-numbers [index.php]
-billing()->middleware('billing.subscribed', function () {
- response()->redirect('/some-special-page');
-});
+billing()->middleware('billing.subscribed', fn () => response()->redirect('/some-special-page'));
```
And then you can use the middleware like this:
@@ -636,27 +720,58 @@ And then you can use the middleware like this:
```php [_some-route.php]
app()->get('/protected', [
'middleware' => 'billing.subscribed',
- function() {
- return 'You are subscribed';
- }
+ fn () => 'You are subscribed'
]);
```
-
+```
+
+Every tier then carries both prices, so your pricing page can show one while you charge the other:
+
+```php
+$tier = billing()->tier('price_starter');
+
+$tier['price']; // 100 charged, in GHS
+$tier['currency']; // 'ghs'
+$tier['displayPrice']; // 7 shown, in USD
+$tier['displayCurrency']; // 'usd'
+$tier['formattedPrice']; // '$7'
+```
+
+The conversion is a fixed rate you control, not a live exchange feed, so update it when your pricing changes. Leave `BILLING_CURRENCY_DISPLAY` unset and prices display in the currency you charge in, which is the safer default.
+
+::: details Formatting amounts yourself
+`Leaf\Billing\Currency` is available anywhere you need to format an amount outside a tier:
+
+```php
+use Leaf\Billing\Currency;
+
+Currency::code(); // 'ghs' what you charge in
+Currency::symbol(); // 'GHβ΅'
+Currency::displayCode(); // 'usd' what you show
+Currency::convert(100); // 7.0
+Currency::format(100); // '$7'
+```
+:::
## Using raw provider instances
@@ -690,7 +805,7 @@ public function handleCartPurchase($cartId) {
$cart->payment_session = $session->id;
$cart->save();
- response()->redirect($session->url);
+ return response()->redirect($session->url);
}
```
diff --git a/src/docs/utils/cache.md b/src/docs/utils/cache.md
index ba0ac318..5ff26bcf 100644
--- a/src/docs/utils/cache.md
+++ b/src/docs/utils/cache.md
@@ -1,6 +1,6 @@
-# Caching New
+# Caching
Imagine you have 1,000 users fetching some common data from your application which requires a complex database query. Instead of running that complex query 1,000 times, you can cache the result of that query and serve the cached result to all 1,000 users. This is where caching comes in handy.
@@ -22,7 +22,28 @@ composer require leafs/cache
:::
-Once the cache module is installed, you can start using it in your application. For now, Leaf's cache only supports file-based caching, so you don't need to do any configuration.
+Once the cache module is installed, you can start using it in your application. File-based caching is supported out of the box, so you don't need to do any configuration.
+
+::: details Cache configuration
+If you need to change where or how data is cached, you can pass a config array when initializing the cache. Your config is merged over the defaults (and over any `config/cache.php` values in MVC apps):
+
+```php
+(new \Leaf\Cache())->init([
+ 'default' => 'file',
+ 'stores' => [
+ 'file' => [
+ 'driver' => 'file',
+ 'path' => __DIR__ . '/storage/framework/cache',
+ ],
+ ],
+ 'prefix' => 'leaf_cache',
+]);
+```
+
+By default, cached files are saved to `storage/framework/cache`: MVC apps resolve this through `StoragePath('framework/cache')`, and outside MVC it's created under your working directory.
+
+The `default` key picks which store from `stores` is used, and it is resolved through Illuminate's cache manager. The file driver works out of the box; other drivers also resolve through Illuminate but need their own bindings set up before they can be used.
+:::
## Using the Cache
@@ -32,13 +53,10 @@ Just like other Leaf modules, you can use the cache module right away by calling
$dataFromDatabase = cache(
'queries.complexQuery', // Unique cache key for this data
60 * 60, // Cache duration in seconds (1 hour)
- function() {
- // Simulate a complex database query
- return db()
- ->select('complex_table')
- ->where('some_column', 'some_value')
- ->get();
- }
+ fn () => db() // Simulate a complex database query
+ ->select('complex_table')
+ ->where('some_column', 'some_value')
+ ->get()
);
```
@@ -48,6 +66,8 @@ In the above example, the `cache()` function takes three parameters:
- **Cache Duration**: The duration (in seconds) for which the data should be cached. In this example, the data will be cached for 1 hour (60 seconds * 60 minutes).
- **Callback Function**: A closure that contains the logic to fetch the data if it's not already cached. This function will only be executed if the cache is empty or has expired.
+Note that only closures are evaluated lazily. If you pass a plain value, it is cached as-is, even if the string happens to match a function name like `'strtolower'`.
+
## How it Works
When you call the `cache()` function, it first checks if the data is already cached, and returns it. If not, it executes the callback function to fetch the data, stores it in the cache, and then returns the fetched data. This way, the complex database query is only executed once every hour, regardless of how many users are requesting the data.
@@ -69,9 +89,7 @@ In this example, we're using the `put()` method to store new data in the cache w
Although caching is typically temporary, there might be scenarios where you want to save certain data permanently in the cache. You can achieve this by using the `cache()` helper function without a duration parameter.
```php:no-line-numbers
-cache('settings.siteConfig', function() {
- return db()->select('settings')->get();
-});
+cache('settings.siteConfig', fn () => db()->select('settings')->get());
```
## Getting Cache Data
@@ -102,7 +120,7 @@ Be cautious when using the `flush()` method, as it will remove all cached data,
## Choosing what to Cache
-Leaf's `cache()` function provides a simple and effective way to implement caching in your application without thinking about the complexities of cache management. By using caching wisely, you can significantly improve the performance of your application and provide a better experience for your users. Remember to choose appropriate cache keys and durations based on the nature of the data being cached, and always consider the trade-offs between data freshness and performance.
+Choose your cache keys and durations based on the nature of the data being cached, and keep the trade-off between data freshness and performance in mind.
For instance, your heaviest queries or computations might include data that changes frequently, so you might want to cache them for shorter durations than more static data. Always analyze your application's specific needs and avoid falling into the trap of over-caching due to "believe me, caching is good" mentality.
diff --git a/src/docs/utils/date.md b/src/docs/utils/date.md
index ebed4f49..be932583 100644
--- a/src/docs/utils/date.md
+++ b/src/docs/utils/date.md
@@ -2,7 +2,7 @@
Working with PHP dates can be challenging due to various date formats, handling different time zones, and tricky date calculations which are actually quite common. These issues can lead to inconsistencies if not managed carefully, and can be a source of bugs in your application.
-Leaf provides a minimalistic module that provides a simple and clean API for working with dates in PHP. It is 100% compatible with PHP's native `DateTime` class, but offers a more fluent and expressive API inspired by Day.js.
+Tick is Leaf's small date module with a simple, clean API for working with dates in PHP. It is 100% compatible with PHP's native `DateTime` class, but offers a more fluent API inspired by Day.js.
```php:no-line-numbers
tick()->now(); // get the current timestamp
@@ -46,6 +46,54 @@ tick($date); // create a date from a DateTime object
Tick is versatile and smart enough to handle dates correctly, so you can pass in any valid date string or timestamp and it will work as expected.
+You can also pass a timezone as the second argument. Just like day.js, this means the date string is a wall-clock time *in* that timezone: "noon in Tokyo", not "noon on my server converted to Tokyo":
+
+```php:no-line-numbers
+// a user in Tokyo schedules a meeting for noon their time
+$meeting = tick('2026-01-15 12:00:00', 'Asia/Tokyo');
+
+$meeting->format('HH:mm'); // 12:00, noon on a Tokyo clock
+$meeting->utc()->format('HH:mm'); // 03:00, the same instant in UTC, ready to store
+```
+
+## Working with timezones
+
+Timezones follow the day.js split: a timezone at construction *parses in* that zone (above), while `tz()` on an existing date *converts* the instant to another clock:
+
+```php:no-line-numbers
+// stored in the database as UTC, rendered for a user in New York
+tick($row['starts_at'], 'UTC')
+ ->tz('America/New_York')
+ ->format('MMM D, hh:mm a');
+```
+
+The full timezone API:
+
+```php:no-line-numbers
+tick()->tz('Asia/Tokyo'); // convert to Tokyo time
+tick()->tz(); // get the current timezone name
+tick()->utc(); // convert to UTC
+tick()->utcOffset(); // offset from UTC in minutes (540 for Tokyo, -240 for New York in summer)
+```
+
+Timezone names can be [any supported timezone](https://www.php.net/manual/en/timezones.php), an offset like `+0200`, or an abbreviation like `BST`. Invalid timezones throw an exception.
+
+The typical calendar-app flow is: parse the user's input in *their* timezone, store it in UTC, and convert to each viewer's timezone when rendering:
+
+```php:no-line-numbers
+// saving: user says "12:00" and their profile says Asia/Tokyo
+$startsAt = tick(request()->get('starts_at'), $user->timezone)
+ ->utc()
+ ->format('YYYY-MM-DD HH:mm:ss');
+
+// rendering: another user in Accra views the event
+tick($event['starts_at'], 'UTC')->tz('Africa/Accra')->format('HH:mm');
+```
+
+::: warning Upgrading from Leaf 4
+In earlier versions, `tick($date, $timezone)` *converted* the parsed date to the timezone instead of parsing in it. If you relied on that, move the timezone to a `tz()` call: `tick($date)->tz($timezone)`.
+:::
+
## Getting and Setting Dates
Tick provides methods for getting and setting various parts of a date, such as the year, month, day, hour, minute, second, and millisecond. This uses a syntax where the same function can be used to get or set a value.
@@ -64,14 +112,11 @@ tick()->year(); // get the year
tick()->month(); // gets current month
tick()->month(0); // returns new tick object
-tick()->day(); // gets day of current week
-tick()->day(0); // returns new tick object
-
-tick()->date(); // gets day of current month
-tick()->date(1); // returns new tick object
+tick()->day(); // gets day of current month
+tick()->day(1); // returns new tick object
tick()->hour(); // gets current hour
-newDate = tick()->hour(12); // returns new tick object
+$newDate = tick()->hour(12); // returns new tick object
tick()->minute(); // gets current minute
tick()->minute(59); // returns new tick object
@@ -223,7 +268,7 @@ tick('2019-01-25')->format('[YYYYescape] YYYY-MM-DDTHH:mm:ssZ[Z]');
// 'YYYYescape 2019-01-25T00:00:000Z'
```
-YYYYescape got ignored instead of turning into a year. This is powerful, especially when you want to include some text in your date format.
+YYYYescape got ignored instead of turning into a year, which is handy when you want to include some text in your date format.
## Time from now
@@ -257,6 +302,19 @@ tick('2014-10-01')->from('2015-10-01', true); // 1 year
tick('2015-10-01')->from('2014-10-01', true); // 1 year
```
+## Difference between dates
+
+Human-readable strings are great for display, but sometimes you need an actual number, like the number of nights between a check-in and a check-out. As of leafs/date 5.1, you can use the `diff()` method to get the difference between two dates as a signed whole number:
+
+```php:no-line-numbers
+tick($checkOut)->diff($checkIn, 'days'); // eg. 3
+tick('2025-01-01')->diff('2025-03-01', 'months'); // -2
+```
+
+`diff()` follows the same semantics as day.js: it takes the date to compare against as its first argument and the unit as its second, and returns a positive number when the tick instance is after the compared date, and a negative one when it's before. The available units are `years`, `months`, `days`, `hours`, `minutes`, and `seconds`, and the compared date can be a string, a `DateTime` object, or another `tick()` instance.
+
+Day differences are calendar-aware, so a stay that crosses a daylight saving boundary still counts the number of calendar days you'd expect instead of drifting by an hour's worth of math.
+
## Querying Dates
Querying dates allows you to check relationships between dates, such as whether a date is before or after another date. Tick provides methods for querying dates, such as `isBefore()`, `isAfter()`, and `isSame()`.
diff --git a/src/docs/utils/fetch.md b/src/docs/utils/fetch.md
index 677f50e0..992e8505 100644
--- a/src/docs/utils/fetch.md
+++ b/src/docs/utils/fetch.md
@@ -1,6 +1,8 @@
# Leaf Fetch
-When building your applications, you will probably end up needing to call APIs or fetch data from external sources. Leaf provides a simple and easy way to do this using Fetch. Fetch provides a clean and modern interface for making network requests in PHP. It is inspired by JavaScript's Fetch API, Axios and uses elements from [Unirest PHP](https://github.com/Kong/unirest-php).
+When building your applications, you will probably end up needing to call APIs or fetch data from external sources. Leaf gives you a simple way to do this with Fetch, a clean interface for making network requests in PHP, inspired by JavaScript's Fetch API and Axios.
+
+Fetch is completely framework-agnostic, so while it feels right at home in a Leaf app, you can use it in Laravel, Symfony, WordPress, or plain PHP. The only requirement is the curl extension.
## Setting Up
@@ -18,15 +20,20 @@ composer require leafs/fetch
:::
-Once installed, you can start using Fetch in your Leaf application.
+Once installed, you can start using Fetch in your application.
## Making Requests
-Fetch provides a simple and easy-to-use interface for making network requests in PHP. You can make GET, POST, PUT, DELETE, and other types of requests using Fetch.
+The quickest way to make a request is to pass a URL straight to the `fetch()` function. A URL on its own is a GET request:
-Fetch also provides a clean and modern interface for working with response data.
+```php:no-line-numbers
+$res = fetch('https://jsonplaceholder.typicode.com/todos/');
-You can write API requests like this:
+// data returned is saved in the $data property just like axios
+response()->json($res->data);
+```
+
+For anything beyond a simple GET, you can describe your whole request with a single config array:
```php
$response = fetch([
@@ -44,7 +51,7 @@ $response = fetch([
]);
```
-Once this request is made, Fetch gives you a `FetchResponse` object which contains the response data, status code, headers, and more.
+Once this request is made, Fetch gives you a response object which contains the response data, status code, headers, and the request that produced it.
```json
"data": [],
@@ -53,62 +60,74 @@ Once this request is made, Fetch gives you a `FetchResponse` object which contai
"request": {}
```
-You will usually want to access the response data. You can do this using the `data` property of the `FetchResponse` object.
+You will usually want to access the response data. You can do this using the `data` property of the response object.
```php:no-line-numbers
response()->json($response->data);
```
-Put it all together and you have a simple and easy way to make network requests in PHP using Fetch.
+## Request Shortcuts
+
+Fetch works just like the Leaf router, in the sense that every request type has a shortcut method. You can call `get()`, `post()`, `put()`, `patch()`, `delete()`, `head()` and `options()` to make any kind of request you want.
```php
-$response = fetch([
- 'method' => 'GET',
- 'url' => 'https://jsonplaceholder.typicode.com/todos/1'
+$res = fetch()->post('https://jsonplaceholder.typicode.com/posts', [
+ 'title' => 'foo',
+ 'body' => 'bar',
+ 'userId' => 1
]);
-response()->json($response->data);
+fetch()->put(...);
+fetch()->patch(...);
+fetch()->delete(...);
+fetch()->options(...);
+
+response()->json($res->data);
```
-It gets even simpler when you're making a GET or POST request. Fetch provides some handy shortcuts to make these requests even easier.
+## Query Parameters
-## Making GET Requests
+On GET requests, anything you pass as `data` is automatically appended to the URL as query parameters, nested arrays included:
-GET requests are the most common type of request you'll make when fetching data from an API. Fetch makes it easy to make GET requests using the global `fetch()` function. Here's an example of how you can make a GET request using Fetch:
+```php
+// requests /posts?page=2&tags[0]=php
+fetch([
+ 'url' => '/posts',
+ 'data' => ['page' => 2, 'tags' => ['php']]
+]);
+```
-```php:no-line-numbers
-$res = fetch()->get('https://jsonplaceholder.typicode.com/todos/');
+If you need query parameters on a non-GET request (where `data` becomes the request body instead), use the `params` option. It appends to the URL for any method:
-// data returned is saved in the $data property just like axios
-response()->json($res->data);
+```php
+// posts to /orders?notify=yes with a JSON body
+fetch([
+ 'method' => 'POST',
+ 'url' => '/orders',
+ 'params' => ['notify' => 'yes'],
+ 'data' => ['sku' => 'leaf-tee'],
+]);
```
-Or you pass the url directly to the `fetch()` function.
+## Request Bodies
-```php:no-line-numbers
-$res = fetch('https://jsonplaceholder.typicode.com/todos/');
+Arrays you pass as `data` on POST, PUT, PATCH and DELETE requests are sent as JSON by default, no `json_encode()` needed:
-// data returned is saved in the $data property just like axios
-response()->json($res->data);
+```php:no-line-numbers
+// sends {"sku":"leaf-tee","qty":2} with your request
+fetch()->post('/orders', ['sku' => 'leaf-tee', 'qty' => 2]);
```
-## Making Other Requests
-
-Fetch works just like the Leaf router, in a sense that every request type has a shortcut method. You can call `get()`, `post()`, `put()`, `patch()`, `delete()` and `options()` to make any kind of request you want.
+If you're talking to an endpoint that expects classic form encoding, just set the Content-Type header and Fetch switches the encoding of the same array for you:
```php
-$res = fetch()->post('https://jsonplaceholder.typicode.com/posts', [
- 'title' => 'foo',
- 'body' => 'bar',
- 'userId' => 1
+// sends sku=leaf-tee as a form body
+fetch([
+ 'method' => 'POST',
+ 'url' => '/legacy/checkout',
+ 'data' => ['sku' => 'leaf-tee'],
+ 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
]);
-
-fetch()->put(...);
-fetch()->patch(...);
-fetch()->delete(...);
-fetch()->options(...);
-
-response()->json($res->data);
```
## Setting Base URLs
@@ -135,9 +154,73 @@ $response = fetch()->post('/posts', [
]);
```
+The base URL only applies to relative URLs: if you pass a full `http://` or `https://` URL, it is used as-is, so you can still call other services without unsetting your base URL.
+
+## Authentication
+
+For HTTP Basic auth, pass your credentials with the `auth` option and Fetch sets up the Authorization header for you:
+
+```php
+fetch([
+ 'url' => '/admin/stats',
+ 'auth' => ['username' => 'mika', 'password' => 'secret'],
+]);
+```
+
+For Bearer tokens and anything else, set the Authorization header directly:
+
+```php
+fetch([
+ 'url' => '/admin/stats',
+ 'headers' => ['Authorization' => 'Bearer my-token'],
+]);
+```
+
+## Working with Responses
+
+Every request returns a response object with four properties:
+
+- `$response->data`: the response body. JSON responses are decoded for you; anything that isn't valid JSON is returned as the raw string.
+- `$response->status`: the HTTP status code, e.g. `200` or `404`.
+- `$response->headers`: the response headers as an array. Header names are always lower cased, so you can reliably read `$response->headers['content-type']`.
+- `$response->request`: the full config of the request that produced this response, useful for debugging.
+
+If you want the untouched response body even for JSON responses, set `rawResponse` to `true` and `data` will always be the raw string:
+
+```php:no-line-numbers
+$res = fetch(['url' => '/report.csv', 'rawResponse' => true]);
+```
+
+Note that Fetch does not throw exceptions for non-2xx status codes: a `404` or `500` still gives you a normal response object, so check `$response->status` when you need to. Fetch only throws an `\Exception` when the request itself fails: an unreachable host, DNS failure or a timeout.
+
+```php
+try {
+ $res = fetch(['url' => 'https://api.example.com/todos', 'timeout' => 5]);
+
+ if ($res->status !== 200) {
+ // handle API errors
+ }
+} catch (\Exception $e) {
+ // handle network errors
+}
+```
+
+## App-wide defaults
+
+If you find yourself passing the same options to every request, you can update the defaults for all subsequent requests using `Fetch::config()`:
+
+```php
+use Leaf\Fetch;
+
+Fetch::config([
+ 'timeout' => 10,
+ 'headers' => ['X-App-Version' => 'v1.2.0'],
+]);
+```
+
## Parameters for Requests
-This is a list of all options you can pass to Fetch when making requests:
+This is the full list of options you can pass to Fetch when making requests, and every one of them is honoured:
```php
[
@@ -147,96 +230,45 @@ This is a list of all options you can pass to Fetch when making requests:
// `method` is the request method to be used when making the request
'method' => 'GET', // default
+ // `baseUrl` is prepended to `url` unless `url` is absolute
+ 'baseUrl' => '',
+
// `headers` are custom headers to be sent
'headers' => [],
- // `params` are the URL parameters to be sent with the request
- // Must be a plain object or a URLSearchParams object
+ // `params` are URL query parameters appended to the URL for ANY method
'params' => [],
- // `data` is the data to be sent as the request body
- // Only applicable for request methods 'PUT', 'POST', 'DELETE , and 'PATCH'
- // When no `transformRequest` is set, must be of one of the following types:
- // - string, plain object, ArrayBuffer, ArrayBufferView, URLSearchParams
- // - Browser 'only' => FormData, File, Blob
- // - Node 'only' => Stream, Buffer
+ // `data` is the data to be sent as the request body. Arrays are JSON
+ // encoded by default; set a Content-Type header of
+ // application/x-www-form-urlencoded to send classic form encoding instead.
+ // On GET requests, `data` is appended to the URL as query parameters.
'data' => [],
- // `timeout` specifies the number of milliseconds before the request times out.
- // If the request takes longer than `timeout`, the request will be aborted.
+ // `timeout` specifies the number of seconds before the request times out.
+ // If the request takes longer than `timeout`, an exception is thrown.
'timeout' => 0, // default is `0` (no timeout)
- // `withCredentials` indicates whether or not cross-site Access-Control requests
- // should be made using credentials
- 'withCredentials' => false, // default
-
- // `auth` indicates that HTTP Basic auth should be used, and supplies credentials.
- // This will set an `Authorization` header, overwriting any existing
- // `Authorization` custom headers you have set using `headers`.
- // Please note that only HTTP Basic auth is configurable through this parameter.
- // For Bearer tokens and such, use `Authorization` custom headers instead.
+ // `auth` indicates that HTTP Basic auth should be used, and supplies credentials:
+ // ['username' => ..., 'password' => ...]
+ // For Bearer tokens and such, use an `Authorization` header instead.
'auth' => [],
- // `responseType` indicates the type of data that the server will respond with
- // options 'are' => 'arraybuffer', 'document', 'json', 'text', 'stream'
- // browser 'only' => 'blob'
- 'responseType' => 'json', // default
-
- // `responseEncoding` indicates encoding to use for decoding responses (Node.js only)
- // 'Note' => Ignored for `responseType` of 'stream' or client-side requests
- 'responseEncoding' => 'utf8', // default
-
- // `xsrfCookieName` is the name of the cookie to use as a value for xsrf token
- 'xsrfCookieName' => 'XSRF-TOKEN', // default
-
- // `xsrfHeaderName` is the name of the http header that carries the xsrf token value
- 'xsrfHeaderName' => 'X-XSRF-TOKEN', // default
-
- // `maxContentLength` defines the max size of the http response content in bytes allowed in node.js
- 'maxContentLength' => 2000,
-
- // `maxBodyLength` (Node only option) defines the max size of the http request content in bytes allowed
- 'maxBodyLength' => 2000,
-
- // `maxRedirects` defines the maximum number of redirects to follow in node.js.
+ // `maxRedirects` defines the maximum number of redirects to follow.
// If set to 0, no redirects will be followed.
'maxRedirects' => 5, // default
- // `socketPath` defines a UNIX Socket to be used in node.js.
- // e.g. '/var/run/docker.sock' to send requests to the docker daemon.
- // Only either `socketPath` or `proxy` can be specified.
- // If both are specified, `socketPath` is used.
- 'socketPath' => null, // default
-
- // `proxy` defines the hostname, port, and protocol of the proxy server.
- // You can also define your proxy using the conventional `http_proxy` and
- // `https_proxy` environment variables. If you are using environment variables
- // for your proxy configuration, you can also define a `no_proxy` environment
- // variable as a comma-separated list of domains that should not be proxied.
- // Use `false` to disable proxies, ignoring environment variables.
- // `auth` indicates that HTTP Basic auth should be used to connect to the proxy, and
- // supplies credentials.
- // This will set an `Proxy-Authorization` header, overwriting any existing
- // `Proxy-Authorization` custom headers you have set using `headers`.
- // If the proxy server uses HTTPS, then you must set the protocol to `https`.
- 'proxy' => [],
-
- // `decompress` indicates whether or not the response body should be decompressed
- // automatically. If set to `true` will also remove the 'content-encoding' header
- // from the responses objects of all decompressed responses
- // - Node only (XHR cannot turn off decompression)
- 'decompress' => true, // default
-
- // If false, fetch will try to parse json responses
+ // If true, fetch will NOT try to parse json responses
'rawResponse' => false,
// CURLOPT_SSL_VERIFYHOST accepts only 0 (false) or 2 (true).
- // Future versions of libcurl will treat values 1 and 2 as equals
'verifyHost' => true, // default
+ // CURLOPT_SSL_VERIFYPEER
'verifyPeer' => true, // default
- // Set additional options for curl.
+ // Set additional options for curl. These are applied last,
+ // so they can override anything Fetch sets up.
'curl' => [],
];
```
diff --git a/src/docs/utils/fs.md b/src/docs/utils/fs.md
index a3930f6a..500f0537 100644
--- a/src/docs/utils/fs.md
+++ b/src/docs/utils/fs.md
@@ -3,7 +3,7 @@
# File Storage System
-A file storage system is a system used to store and manage files. It's a crucial part of most applications, as it helps you create, read, update, store and delete files effectively. Leaf provides a simple and easy-to-use file storage system that allows you to work with files on your server or in the cloud.
+Most applications need to create, read, update, store and delete files at some point. Leaf provides a simple file storage system for working with files on your server or in the cloud.
## Installation
@@ -25,7 +25,9 @@ That's it! You can now use the `storage()`/`path()` functions from anywhere in y
## Working with file paths
-File paths are the locations of files on your server. They help you locate and interact with files effectively. While they are essential for working with files, they can be a bit tricky to work with. Leaf provides a simple way to work with file paths using the `path()` function.
+File paths are the locations of files on your server. They help you locate and interact with files effectively. While they are essential for working with files, they can be a bit tricky to work with.
+
+Leaf provides a simple way to work with file paths using the `path()` function.
### Getting information out of a path
@@ -65,7 +67,7 @@ echo $path; // path/to/file.txt
## Working with Files
-Working with files is a crucial part of most applications. Leaf provides a simple and easy-to-use file system that allows you to create, read, update, and delete files effectively using the `storage()` function.
+You can create, read, update, and delete files using the `storage()` function.
### Creating Files
@@ -82,9 +84,7 @@ If you don't provide the file content, Leaf will create an empty file for you.
```php
storage()->createFile('path/to/file.txt');
-storage()->createFile('path/to/file.txt', function () {
- return 'Hello, world!';
-});
+storage()->createFile('path/to/file.txt', fn () => 'Hello, world!');
storage()->createFile('path/to/file.txt', 'Hello, world!', [
'overwrite' => true
@@ -113,6 +113,28 @@ $content = storage()->read('path/to/file.txt');
echo $content;
```
+### Reading part of a file
+
+For big files you often don't want the whole thing, just a slice. `readRange()` reads an exact byte window without loading the rest of the file:
+
+```php
+$firstKb = storage()->readRange('video.mp4', 0, 1024); // first 1KB
+$middle = storage()->readRange('video.mp4', 500, 100); // 100 bytes from byte 500
+$tail = storage()->readRange('logs/app.log', -2048); // last 2KB
+```
+
+### Streaming large files
+
+Serving a 5GB download shouldn't need 5GB of memory. `chunks()` streams a file piece by piece, so memory stays flat no matter the file size. That's exactly what you want for large downloads or HTTP range responses:
+
+```php
+foreach (storage()->chunks('backup.zip', 1024 * 1024) as $chunk) {
+ echo $chunk; // one 1MB piece at a time
+}
+```
+
+You can stream just a window of the file too with `chunks($path, $chunkSize, $start, $length)`, which pairs naturally with the HTTP `Range` header for resumable and multi-threaded downloads.
+
### Updating Files
You can update files using the `writeFile()` method. It takes in the file path and the content to set or a function that returns the file content.
@@ -120,17 +142,13 @@ You can update files using the `writeFile()` method. It takes in the file path a
```php
storage()->writeFile('path/to/file.txt', 'Hello, world!');
-storage()->writeFile('path/to/file.txt', function () {
- return 'Hello, world!';
-});
+storage()->writeFile('path/to/file.txt', fn () => 'Hello, world!');
```
If the file is a readable file, the `writeFile()` method will provide the current content of the file to the function.
```php
-storage()->writeFile('path/to/file.txt', function ($content) {
- return $content . ' Hello, world!';
-});
+storage()->writeFile('path/to/file.txt', fn ($content) => $content . ' Hello, world!');
```
### Getting File Information
@@ -168,7 +186,7 @@ if ($uploaded) {
The `upload()` method automatically grabs the file from the request, so you don't have to worry about all of that.
-One amazing thing about the `upload()` method is that it can detect the file type and automatically handle any associated configuration. If you need to customize the upload configuration, you can pass an array of configuration options as the third parameter.
+The `upload()` method can also detect the file type and automatically handle any associated configuration. If you need to customize the upload configuration, you can pass an array of configuration options as the third parameter.
```php
$uploaded = request()->upload('fileToUpload', 'path/to/uploads', [
@@ -231,6 +249,18 @@ if ($uploaded) {
}
```
+### Uploading a file you already have
+
+`upload()` isn't only for request files: you can hand it a plain path and Leaf will place a **copy** in the destination (your original file stays where it is). Handy for CLI tools, queued jobs, or shipping generated files into your uploads/bucket:
+
+```php
+$uploaded = storage()->upload('/path/to/report.pdf', 'path/to/uploads');
+
+echo $uploaded['name']; // report.pdf
+```
+
+All the same config options apply: `validate`, `allowedTypes`, `overwrite`, `rename` and friends.
+
## Uploading multiple files
You may need to allow users enter multiple files at once on the same input, for example, uploading multiple documents to a teacher's portal. Leaf's `upload()` now automatically handles multiple files under the same input.
@@ -276,11 +306,11 @@ if ($uploaded) {
}
```
-## Using s3 or other cloud storage services NEW - WIP
+## Using S3 or other cloud storage services
Leaf FS now supports using Amazon s3 and other cloud storage services that support the S3 protocol. This allows you to switch from local storage to cloud storage without changing any code. To get started, you need to configure your cloud storage settings in the `.env` file.
-```env
+```txt
AWS_ACCESS_KEY_ID=1234567890abcdef1234
AWS_SECRET_ACCESS_KEY=1234567890abcdef1234567890abcdef1234
AWS_DEFAULT_REGION=weur
@@ -323,11 +353,91 @@ $videoUrl = storage()->createFile(
);
```
-We are working on a 100% interchangeable API for local and cloud storage, so you can use `withBucket()` anywhere you would normally use a local path, however, some methods may not be supported yet. We would love to hear your feedback on this feature.
+Uploads are public by default, but you can pass a `visibility` option to keep a file private, and files uploaded as private stay private:
+
+```php
+storage()->createFile(
+ withBucket('exports/report.csv'),
+ $csvContent,
+ ['visibility' => 'private']
+);
+```
+
+`createFile()` writes to the exact path you give it, so `withBucket('docs/note.txt')` creates `docs/note.txt` in your bucket.
+
+::: details Configuring connections manually
+If you configure a bucket connection yourself instead of using the `.env` values above, the connection expects an `endpoint` key for the bucket URL:
+
+```php
+\Leaf\FS\Bucket::connections([
+ 's3' => [
+ 'endpoint' => 'https://something.r2.cloudflarestorage.com',
+ 'key' => '...',
+ 'secret' => '...',
+ 'bucket' => 'bucket-name',
+ 'region' => 'auto',
+ ],
+]);
+```
+
+A connection missing any of `endpoint`, `key`, `secret` or `bucket` throws an exception that names the missing key.
+:::
+
+### What works with bucket paths
+
+`withBucket()` produces a path you can hand to the same storage methods you already use, so moving a file from local disk to cloud storage is a change of destination rather than a change of code:
+
+```php
+$path = withBucket('reports/q1.csv');
+
+storage()->createFile($path, $csv);
+storage()->exists($path); // true
+storage()->read($path); // the csv back
+storage()->write($path, $csv2); // replace it
+storage()->size($path); // bytes, or pass 'kb', 'mb'
+storage()->lastModified($path); // unix timestamp
+storage()->mimeType($path);
+storage()->delete($path);
+```
+
+`request()->upload()` takes a bucket path as its destination too.
+
+::: details Working with the bucket directly
+`Leaf\FS\Bucket` is available when you want bucket operations without going through a storage path, and it's the only way to reach bucket-side copies, moves and listings:
+
+```php
+use Leaf\FS\Bucket;
+
+Bucket::exists('reports/q1.csv');
+Bucket::read('reports/q1.csv');
+Bucket::write('reports/q1.csv', $csv);
+Bucket::delete('reports/q1.csv');
+Bucket::copy('a.txt', 'b.txt');
+Bucket::move('a.txt', 'archive/a.txt');
+Bucket::size('a.txt');
+Bucket::lastModified('a.txt');
+Bucket::mimeType('a.txt');
+Bucket::list('reports', true); // recursive
+```
+
+Every method returns `false` (or an empty array for `list()`) instead of throwing when something goes wrong, and the reason lands in `Bucket::errors()`.
+:::
+
+::: details Methods that are still local-only
+Directory operations (`createFolder()`, `listFolder()` and friends), `copy()` and `move()` between a bucket and local disk, and the streaming helpers `chunks()` and `readRange()` still expect local paths. Reach for `Leaf\FS\Bucket` directly if you need bucket-side copies, moves or listings:
+
+```php
+use Leaf\FS\Bucket;
+
+Bucket::copy('a.txt', 'b.txt');
+Bucket::move('a.txt', 'archive/a.txt');
+Bucket::list('reports', true); // recursive
+```
+:::
## Working with Folders
-Working with folders is an essential part of most applications. Leaf provides a simple and easy-to-use file system that allows you to create, read, update, and delete folders effectively using the `storage()` function.
+You can create, read, update, and delete folders with the `storage()` function too.
### Creating Folders
@@ -378,10 +488,11 @@ $phpFiles = storage()->list('path/to/folder', '*.php');
If you need to do more complex filtering, you can pass a function as the second parameter to the `list()` method.
```php
-$contents = storage()->list('path/to/folder', function ($file) {
- // if true, the file will be included in the results
- return storage()->isFile($file) && storage()->extension($file) === 'php';
-});
+// if the callback returns true, the file will be included in the results
+$contents = storage()->list(
+ 'path/to/folder',
+ fn ($file) => storage()->isFile($file) && storage()->extension($file) === 'php'
+);
```
@@ -506,7 +617,7 @@ if ($moved) {
## Symlinks/Shortcuts
-Symlinks are shortcuts to files or folders. They allow you to access a file or folder from a different location. Leaf provides a simple way to create symlinks using the `symlink()` method. It takes in 2 parameters:
+Symlinks are shortcuts to files or folders. They allow you to access a file or folder from a different location. Leaf provides a simple way to create symlinks using the `link()` method. It takes in 2 parameters:
- the target file/folder path
- the symlink path
@@ -526,13 +637,4 @@ if ($linked) {
}
```
-
diff --git a/src/docs/utils/lingo.md b/src/docs/utils/lingo.md
index cf961113..0043e8d0 100644
--- a/src/docs/utils/lingo.md
+++ b/src/docs/utils/lingo.md
@@ -1,5 +1,5 @@
-# Multi-locale support BETA
+# Multi-locale support
English is the most widely used language on the web, but it's far from the only one. Supporting multiple languages helps you reach a global audience. Leaf Lingo provides an official solution for adding multi-language support to your Leaf applications, without restructuring your code or adding middleware.
@@ -29,6 +29,16 @@ Once installed, Lingo automatically sets up everything you need for multi-langua
hero.title: "Bonjour le monde"
```
+You can also nest your translations instead of writing out full dot keys. Nested maps are flattened automatically, so both styles produce the same keys:
+
+```yaml:no-line-numbers
+# app/locales/fr.yml
+
+hero:
+ title: "Bonjour le monde"
+ subtitle: "Bienvenue sur notre site"
+```
+
Now you can use the `lingo()` helper function to translate strings in your views or controllers:
```php:no-line-numbers
@@ -41,18 +51,39 @@ In Blade templates, use it the same way:
@lingo('hero.title')
```
+## Configuring Lingo
+
+Lingo works out of the box, but you can tweak how it behaves by passing a config array to `lingo()->create()`:
+
+```php
+lingo()->create([
+ 'locales.default' => 'en_US',
+ 'locales.path' => 'app/locales',
+ 'locales.strategy' => 'session',
+]);
+```
+
+::: details All config options
+
+| Option | Default | Description |
+| --- | --- | --- |
+| `locales.default` | `en_US` | The locale used when no other locale can be determined. |
+| `locales.available` | `[]` | Filled automatically from your translation files, one locale per `.yml` file. |
+| `locales.path` | `locales` | The folder containing your translation files. |
+| `locales.strategy` | `router` | How Lingo determines the current locale: `router`, `header`, `session` or `custom`. |
+| `locales.customStrategy` | `null` | The handler class to use when the strategy is set to `custom`. |
+| `locales.cacheKey` | `__lingo.locale__` | The session key used to remember the selected locale in session mode. |
+
+:::
+
## Lingo Modes
By default, Lingo uses routes for the translation strategy which means that if you have routes like this in your Leaf app:
```php
-app()->get('/home', function() {
- return response()->render('home');
-});
+app()->get('/home', fn () => response()->render('home'));
-app()->get('/about', function() {
- return response()->render('about');
-});
+app()->get('/about', fn () => response()->render('about'));
```
Lingo automatically creates routes for each language:
@@ -64,23 +95,27 @@ Lingo automatically creates routes for each language:
/fr/about
```
-The routes are generated based on your translation files. If you have `de.yml` and `fr.yml`, Lingo creates routes for `/de/*` and `/fr/*`. It also redirects requests to base paths (like `/home`) to the default language route (e.g., `/en/home`). Set your default language in `.env` with `APP_LOCALE=...`.
+The routes are generated based on your translation files. If you have `de.yml` and `fr.yml`, Lingo creates routes for `/de/*` and `/fr/*`.
-### Header Mode Experimental
+It also redirects requests to base paths (like `/home`) to the default language route (e.g., `/en/home`). Set your default language in `.env` with `APP_LOCALE=...`.
+
+### Header Mode
Use header mode when building an API that needs to support multiple languages via the `Accept-Language` header. In this mode, Lingo doesn't create language-specific routes. Instead, it determines the language from the `Accept-Language` header sent by the client.
For example, a request with `Accept-Language: fr` uses French translations, even though the route isn't prefixed with `/fr`.
+Lingo parses the full header, including quality values, so a header like `en-GB,en;q=0.9,fr;q=0.8` is checked in order of preference. `en-GB` maps to `en_GB`, and if there's no exact match Lingo falls back to the plain language file (`en.yml`), then to any regional file for that language (like `en_US.yml`), before settling on your default locale.
+
To enable header mode, you need to set the following in your `.env` file:
```env
LOCALES_STRATEGY=header
```
-### Session Mode Experimental
+### Session Mode
-Use session mode when you want users to switch languages without changing the URL. Lingo stores the selected language in the user's session, so all subsequent requests use that language. Like header mode, session mode doesn't create language-specific routes.
+Use session mode when you want users to switch languages without changing the URL. Lingo stores the selected language in the user's session, so the choice is remembered across requests until they pick another language. Like header mode, session mode doesn't create language-specific routes.
To enable session mode, you need to set the following in your `.env` file:
@@ -88,9 +123,54 @@ To enable session mode, you need to set the following in your `.env` file:
LOCALES_STRATEGY=session
```
+### Custom Strategies
+
+If none of the built-in strategies fit your app, you can write your own. A strategy is a class implementing the `Leaf\Lingo\Handler` interface, which has three methods: `create()` for setup, `setCurrentLocale()` and `getCurrentLocale()`.
+
+```php
+use Leaf\Lingo\Handler;
+
+class SubdomainStrategy implements Handler
+{
+ protected static array $config = [];
+
+ public static function create(array $config): static
+ {
+ static::$config = $config;
+ return new static();
+ }
+
+ public static function setCurrentLocale(string $locale): void
+ {
+ // save the user's selected locale
+ }
+
+ public static function getCurrentLocale(): ?string
+ {
+ // determine the locale, e.g. from a subdomain like fr.example.com
+ $subdomain = explode('.', $_SERVER['HTTP_HOST'] ?? '')[0];
+
+ return in_array($subdomain, static::$config['locales.available'])
+ ? $subdomain
+ : static::$config['locales.default'];
+ }
+}
+```
+
+You can then tell Lingo to use your strategy:
+
+```php
+lingo()->create([
+ 'locales.strategy' => 'custom',
+ 'locales.customStrategy' => SubdomainStrategy::class,
+]);
+```
+
## Switching Locales
-Lingo uses the same approach for switching locales across all modes. Use the `lingo()->setCurrentLocale()` method to create a route that handles locale switching. The method switches the current locale based on your configured strategy (routes or session). In header mode, this method has no effect since the locale is determined by the `Accept-Language` header.
+Lingo uses the same approach for switching locales across all modes. Use the `lingo()->setCurrentLocale()` method to create a route that handles locale switching. The method switches the current locale based on your configured strategy (routes or session).
+
+In header mode, this method has no effect since the locale is determined by the `Accept-Language` header.
Here is an example of how to create a route for switching locales:
@@ -207,7 +287,7 @@ app()->get('/contact', [
]);
```
-This is useful for routes that shouldn't be localized, like API endpoints or routes that should be language-independent.r
+This is useful for routes that shouldn't be localized, like API endpoints or routes that should be language-independent.
## Lingo URL Router Mode Only
diff --git a/src/docs/utils/mail/index.md b/src/docs/utils/mail/index.md
index 1da459a4..2f0cf15d 100644
--- a/src/docs/utils/mail/index.md
+++ b/src/docs/utils/mail/index.md
@@ -2,48 +2,31 @@
-
+
+
+
+
Product email
+
Send transactional email without fighting PHP mail setup.
+
Leaf Mail wraps PHPMailer with a Leaf-friendly API for SMTP, Gmail, Mailgun, SendGrid, Amazon SES, sendmail, and MVC mailers.
+
+
+
+
$ leaf install mail
+
mailer()->create([
+
'subject' => 'Welcome to Leaf'
+
])->send();
+
+
+
Using Leaf MVC?
+
Use the MVC mail guide for mailer classes, environment config, and app-ready examples.
+
Mailing in PHP apps has always been seen as a daunting task. Leaf Mail provides a simple, straightforward and efficient email API that is built on the widely used [PHPMailer Library](https://github.com/PHPMailer/PHPMailer) component.
-With Leaf Mail, you can easily send emails using various drivers and services such as SMTP, Mailgun, SendGrid, Amazon SES, and sendmail. This flexibility enables you to swiftly begin sending emails through a preferred local or cloud-based service.
-
-
-
-
-
- Using Leaf MVC?
-
-
- We've crafted a specialized guide for routing in Leaf MVC. While it's similar to the mailing in Leaf, it's more detailed and tailored for Leaf MVC.
-
-
-
-
-
-
-
-
## Setting Up
You can install leaf mail using the leaf cli:
@@ -143,7 +126,9 @@ mailer()->connect([
## Writing mails
-Once we have all the annoying stuff out of the way, we can now write our emails. This involves creating a new mail and then sending it when you're ready. At it's core, a mail is just a class call to the `mail()->create()` method. This method takes in the name of the mail you want to create and returns a new mail object.
+Once we have all the annoying stuff out of the way, we can now write our emails. This involves creating a new mail and then sending it when you're ready.
+
+At it's core, a mail is just a class call to the `mail()->create()` method. This method takes in the name of the mail you want to create and returns a new mail object.
```php
mailer()->create([
@@ -168,9 +153,9 @@ The `create()` method takes in an array of options that you can use to configure
| senderEmail | The email of the person sending the mail | No |
| replyToName | Add a name for your "Reply-To" address | No |
| replyToEmail | Add a "Reply-To" address | No |
-| cc | The email of the person you want to carbon copy | No |
-| bcc | The email of the person you want to blank carbon copy | No |
-| isHTML | A boolean value that determines if your mail is HTML or not | No |
+| cc | The email(s) to carbon copy. Takes a single address or an array of addresses | No |
+| bcc | The email(s) to blank carbon copy. Takes a single address or an array of addresses | No |
+| isHtml | A boolean value that determines if your mail is HTML or not | No |
| altBody | This body can be read by mail clients that do not have HTML email capability such as mutt & Eudora. Clients that can read HTML will view the normal Body | No |
## Sending mails
@@ -188,6 +173,10 @@ $mail = mailer()->create([
$mail->send();
```
+You need an active connection before sending: calling `send()` without `connect()` throws an exception telling you to connect first. If the mail server rejects a send, `send()` returns `false` and the error is saved for you to inspect. See [error handling](#error-handling) below.
+
+Leaf Mail is also safe to use in long-running processes like queue workers, since each send starts clean and recipients from earlier sends don't carry over.
+
## Adding Attachments
You can add attachments to your mail using the `attach()` method. This method takes in the path to the file you want to attach or an array of paths to multiple files.
@@ -205,6 +194,16 @@ mailer()
]);
```
+You can also pass attachments directly in the options when creating the mail, using the `attachments` key:
+
+```php
+mailer()->create([
+ 'subject' => 'Leaf Mail Test',
+ 'body' => 'This is a test mail from Leaf Mail using gmail',
+ 'attachments' => ['./file1.txt', './file2.txt'],
+]);
+```
+
## Setting default values
Some values like the sender email, and other values are common across all your mails so repeating them in every mail can be a bit annoying. To solve this, you can set default values for your mails. This can be done using the `defaults` option in the mailer config:
@@ -224,7 +223,7 @@ Some values like the sender email, and other values are common across all your m
]
```
-This allows you to focus on only the necessary values when creating your mails.
+This allows you to focus on only the necessary values when creating your mails. Any value you pass to `create()` overrides the default, so a mail can set its own `replyToEmail` and `replyToName` while every other mail falls back to the ones from `defaults`.
```php
mailer()->create([
@@ -247,12 +246,14 @@ You can enable debugging for your mails using the `debug` option in the mailer c
## Error Handling
-In order not to flood your application with logs and errors, Leaf Mail gathers all errors thrown by the mail server, and saves them internally. You can return all errors with `$mail->errors()`
+In order not to flood your application with logs and errors, Leaf Mail gathers all errors thrown by the mail server, and saves them internally. When a send fails, `send()` returns `false` and you can return all errors with `mailer()->errors()`
```php
-if (!$mail->send(...)) {
- return $mail->errors();
+use Leaf\Mail\Mailer;
+
+if (!$mail->send()) {
+ return mailer()->errors();
}
```
-Note that these errors are tied to the specific mail object and are only available after the mail has been sent.
+Note that these errors are only available after a send has been attempted.
diff --git a/src/docs/utils/mail/mvc.md b/src/docs/utils/mail/mvc.md
index fd43056f..faa564c8 100644
--- a/src/docs/utils/mail/mvc.md
+++ b/src/docs/utils/mail/mvc.md
@@ -1,6 +1,8 @@
# Mailing with Leaf MVC
-Leaf MVC provides a simple and easy-to-use interface for sending emails in PHP. You create mailers, write your email content, and send your emails. To get started with mailing in Leaf MVC, you need to install the Leaf Mail package. You can do this using the Leaf CLI:
+Leaf MVC provides a simple and easy-to-use interface for sending emails in PHP. You create mailers, write your email content, and send your emails.
+
+To get started with mailing in Leaf MVC, you need to install the Leaf Mail package. You can do this using the Leaf CLI:
::: code-group
@@ -36,7 +38,7 @@ That's it! Leaf Mail will automatically connect to your mail server when you sen
## Writing Emails
-In Leaf MVC, emails are handled through **mailers**βdedicated classes that keep your email logic clean and structured. Instead of mixing email-sending code throughout your app, mailers centralize everything in one place. For example, a `WelcomeMailer` can manage all welcome emails, with separate methods for different messages. With the MVC console, you can generate mailers instantly, making it easy to manage and scale email functionality while keeping your code simple and maintainable..
+In Leaf MVC, emails are handled through **mailers**: dedicated classes that hold your email logic in one place instead of scattering send calls through your app. A `WelcomeMailer` can own every welcome email, with a method per message. The MVC console generates mailers for you.
```bash:no-line-numbers
leaf g:mailer welcome
@@ -94,9 +96,9 @@ The `create()` method takes in an array of options that you can use to configure
| senderEmail | The email of the person sending the mail | No |
| replyToName | Add a name for your "Reply-To" address | No |
| replyToEmail | Add a "Reply-To" address | No |
-| cc | The email of the person you want to carbon copy | No |
-| bcc | The email of the person you want to blank carbon copy | No |
-| isHTML | A boolean value that determines if your mail is HTML or not | No |
+| cc | The email(s) to carbon copy. Takes a single address or an array of addresses | No |
+| bcc | The email(s) to blank carbon copy. Takes a single address or an array of addresses | No |
+| isHtml | A boolean value that determines if your mail is HTML or not | No |
| altBody | This body can be read by mail clients that do not have HTML email capability such as mutt & Eudora. Clients that can read HTML will view the normal Body | No |
## Sending Emails
@@ -117,6 +119,10 @@ public function handle($userId)
That's it! Your email will be sent to the recipient.
+Sending requires an active mail server connection, so if your environment variables or mail config are missing, `send()` throws an exception telling you to connect first. If the mail server rejects a send, `send()` returns `false` and the error is saved for you to inspect. See [error handling](#error-handling) below.
+
+Leaf Mail is also safe to use in long-running processes like queue workers, since each send starts clean and recipients from earlier sends don't carry over.
+
## Adding Attachments
You can add attachments to your mail using the `attach()` method. This method takes in the path to the file you want to attach or an array of paths to multiple files.
@@ -134,6 +140,16 @@ mailer()
]);
```
+You can also pass attachments directly in the options when creating the mail, using the `attachments` key:
+
+```php
+mailer()->create([
+ 'subject' => 'Leaf Mail Test',
+ 'body' => 'This is a test mail from Leaf Mail using gmail',
+ 'attachments' => ['./file1.txt', './file2.txt'],
+]);
+```
+
## Mail Templates
Templates are a great way to keep your email content clean and structured. Leaf Mail supports Blade templates, which means you can use Blade syntax in your email templates. To use a template, you can pass the path to the template file as the `body` option.
@@ -167,7 +183,7 @@ Some values like the sender email, and other values are common across all your m
]
```
-This allows you to focus on only the necessary values when creating your mails.
+This allows you to focus on only the necessary values when creating your mails. Any value you pass to `create()` overrides the default, so a mail can set its own `replyToEmail` and `replyToName` while every other mail falls back to the ones from `defaults`.
```php
mailer()->create([
@@ -190,15 +206,17 @@ You can enable debugging for your mails using the `debug` option in the mailer c
## Error Handling
-In order not to flood your application with logs and errors, Leaf Mail gathers all errors thrown by the mail server, and saves them internally. You can return all errors with `$mail->errors()`
+In order not to flood your application with logs and errors, Leaf Mail gathers all errors thrown by the mail server, and saves them internally. When a send fails, `send()` returns `false` and you can return all errors with `mailer()->errors()`
```php
-if (!$mail->send(...)) {
- return $mail->errors();
+use Leaf\Mail\Mailer;
+
+if (!$mail->send()) {
+ return mailer()->errors();
}
```
-Note that these errors are tied to the specific mail object and are only available after the mail has been sent.
+Note that these errors are only available after a send has been attempted.
## Configuring Mailer
diff --git a/src/docs/utils/queues.md b/src/docs/utils/queues.md
index 35ead866..20daa1b2 100644
--- a/src/docs/utils/queues.md
+++ b/src/docs/utils/queues.md
@@ -2,48 +2,27 @@
-
-
-Some tasks, like processing large CSV uploads, can slow down your app and hurt the user experience. Leaf makes it easy to offload heavy work to background jobs, keeping your app fast and responsive. With built-in queuing, you get better performance without the complexity.
-
-
-
-
-
-
-
- Queues are only supported by Leaf MVC. We plan to add support for Leaf Core in the near future.
-
-
-
-
-
+
+
+
+
Background work
+
Move slow tasks out of the request path.
+
Leaf queues let MVC apps dispatch jobs, run workers, and keep user-facing requests fast while emails, imports, reports, and AI-heavy tasks run separately.
+
+
+
$ leaf install queue
+
dispatch(SendEmailJob::with($userId));
+
leaf queue:work
+
+
+ Queues are currently supported in Leaf MVC. Core support is planned, but MVC apps can use jobs and workers today.
+
+
+
-
+Some tasks, like processing large CSV uploads, sending emails, or generating reports, can slow down your app and hurt the user experience. Leaf makes it easy to offload heavy work to background jobs, keeping your app fast and responsive.
Leaf queues have three parts:
@@ -51,7 +30,7 @@ Leaf queues have three parts:
- the job (task to run eg: sending emails)
- the worker (processes jobs eg: terminal process)
-You write your tasks as jobs, dispatch them to a queue so they can be processed later, and run a worker to process the jobs in the background. That way, your app stays fast and responsive while handling heavy tasks in the background. This might sound complicated, but Leaf makes it super easy to get started.
+You write your tasks as jobs, dispatch them to a queue so they can be processed later, and run a worker to process the jobs in the background. This might sound complicated, but Leaf makes it super easy to get started.
## Installation
@@ -69,7 +48,7 @@ composer require leafs/queue
:::
-By default, Leaf MVC uses your database as the queue backend, storing jobs in a `leaf_php_jobs` table. If you're fine with these defaults, just restart your serverβLeaf will detect the queue setup and automatically start processing jobs alongside the PHP and Vite servers.
+By default, Leaf MVC uses your database as the queue backend, storing jobs in a `leaf_php_jobs` table. If you're fine with these defaults, just restart your server, and Leaf will detect the queue setup and automatically start processing jobs alongside the PHP and Vite servers.
## Creating a job
@@ -97,7 +76,7 @@ class SendEmailJob extends Job
* Handle the job.
* @return void
*/
- public function handle($userId)
+ public function handle($userId): void
{
UserMailer::welcome($userId)->send();
}
@@ -138,7 +117,9 @@ After dispatching the job, you need a worker to run all jobs in the queue.
## Starting a worker
-Workers are the final piece of the puzzle. A worker is a process that runs in the background and processes jobs from the queue. Without a worker running, your jobs will just sit in the queue without being processed. Leaf will automatically start a worker for you when you start the PHP server using `leaf serve`. However, if you want to start a worker manually, you can use the `queue:work` command:
+Workers are the final piece of the puzzle. A worker is a process that runs in the background and processes jobs from the queue. Without a worker running, your jobs will just sit in the queue without being processed.
+
+Leaf will automatically start a worker for you when you start the PHP server using `leaf serve`. However, if you want to start a worker manually, you can use the `queue:work` command:
```bash:no-line-numbers
leaf queue:work
@@ -167,7 +148,7 @@ class SendEmailJob extends Job
* Handle the job.
* @return void
*/
- public function handle($userId)
+ public function handle($userId): void
{
UserMailer::welcome($userId)->send();
}
@@ -186,9 +167,11 @@ The available options are:
| timeout | The number of seconds a child process can run before being killed. |
| tries | The maximum number of times a job may be attempted. |
-## Scheduling jobs NEW
+## Scheduling jobs
+
+Some background tasks need to be run at specific times or intervals, for instance, every week, you get an email report of your app's activity. This is usually done using CRON jobs, but Leaf allows you to schedule jobs directly from your already existing jobs.
-Some background tasks need to be run at specific times or intervals, for instance, every week, you get an email report of your app's activity. This is usually done using CRON jobs, but Leaf allows you to schedule jobs directly from your already existing jobs. Let's take an example of sending an application report to the admin every week. First, you create a job that sends the report:
+Let's take an example of sending an application report to the admin every week. First, you create a job that sends the report:
```php
send();
}
@@ -227,7 +210,7 @@ class SendAppReportJob extends Job
* Handle the job.
* @return void
*/
- public function handle()
+ public function handle(): void
{
AdminMailer::applicationReport()->send();
}
@@ -256,7 +239,7 @@ While this human-readable syntax is great for most use cases, you can also use C
-# Sitemap Generator Beta
+# Sitemap Generator
Modern search engines can crawl and index your website without any setup, but having a sitemap can help search engines understand your website structure and improve how your pages are discovered and indexed. This is especially important for larger websites with many pages, or for websites that have a lot of dynamic content that may not be easily discoverable by search engines.
@@ -42,6 +42,14 @@ To regenerate your sitemap, you can:
sitemap()->generate();
```
+If you'd rather have your sitemap refresh itself on a schedule, you can set a max age in seconds. When the existing `sitemap.xml` is older than this value, Leaf regenerates it on the next request:
+
+```php:no-line-numbers
+\Leaf\Sitemap::$maxAge = 86400; // regenerate after a day
+```
+
+Leaving `$maxAge` unset keeps the default behavior: the sitemap is generated once and only refreshed when you do so manually.
+
## Auto Sitemaps
Since sitemaps automatically use your Leaf routes, you can add some config options directly to your routes to customize how they appear in the sitemap. For example:
@@ -58,28 +66,13 @@ app()->get('/about', [
]);
```
-If you have a dynamic route like `/blog/{slug}`, you can also add the sitemap config to the route, you can tell the sitemap generator to replace the `{slug}` parameter with actual values from your model if you have one defined:
-
-```php
-app()->get('/blog/{slug}', [
- 'sitemap' => [
- 'changefreq' => 'weekly',
- 'priority' => 0.8,
- 'model' => \App\Models\Post::class, // or 'posts' if you don't have a model but have a table named 'posts',
- 'parameter' => 'slug', // the parameter in the route to replace with the model value,
- 'exclude' => [
- 'status' => 'draft' // you can also exclude certain items from the sitemap based on model attributes
- ]
- ],
- 'BlogController@show'
-]);
-```
+You can pass `changefreq`, `priority` and `lastmod` here. A `lastmod` entry only shows up in the sitemap when you provide one, since guessing a modification date would mislead search engines. `priority` defaults to `0.5`, and an explicit `0` is respected for pages you want crawled last.
-This is typically a faster way to generate sitemaps for dynamic routes, as you don't have to manually add a datasource and fetch the data yourself, the sitemap generator will handle it for you. So you will only need to manually add a datasource if you want to do more complex link generation like adding multiple URLs for the same route, or if you want to add URLs that are not defined as routes in your application.
+Dynamic routes like `/blog/{slug}` need real URLs before they can appear in your sitemap. You provide those with a datasource.
## Datasources
-Your application may have dynamic routes, eg: `/blog/{slug}`. To include these routes in your sitemap, you can create a custom datasource that fetches the necessary data from your database and adds it to the sitemap. Here's an example of how to create a custom datasource for a blog:
+Your application may have dynamic routes, eg: `/blog/{slug}`. These routes are left out of your sitemap entirely unless you map them to real URLs, since a raw pattern like `/blog/{slug}` is not a page search engines can visit. To include these routes in your sitemap, you can create a custom datasource that fetches the necessary data from your database and adds it to the sitemap. Here's an example of how to create a custom datasource for a blog:
```php
sitemap()->source(function() {
diff --git a/src/docs/utils/testing.md b/src/docs/utils/testing.md
index 90fdb59b..1927e112 100644
--- a/src/docs/utils/testing.md
+++ b/src/docs/utils/testing.md
@@ -1,12 +1,25 @@
-# Testing & Code Styling
+# Testing & Code Quality
-Testing helps you and your team build Leaf apps faster by making sure that new features and changes to existing code breaks nothing else. Testing also encourages you to organize your app into smaller, easier-to-manage parts like functions, modules, and components.
+Most PHP projects end up with the same pile of QA config: a `phpunit.xml`, a `.php-cs-fixer.php`, maybe a `rector.php` and a `phpstan.neon`, plus hand-written CI workflows to run them all. None of it is hard. It is just setup you have to get right in four different formats, and keep in sync forever.
-Since you might need to setup a project for rapid prototyping and deployment, we don't add any tests to the default Leaf installation. However, we have Alchemy, a user-friendly tool that simplifies your testing, code styling checks, and code coverage reports with a single command.
+Alchemy replaces that pile with one file. You describe what you want in `alchemy.yml`, and Alchemy handles the rest: tests with Pest or PHPUnit, code style with PHP CS Fixer, refactoring with Rector, static analysis with PHPStan, and CI pipelines for GitHub Actions, GitLab CI or CircleCI.
+
+Alchemy works in any PHP project, not just Leaf. It detects Laravel, Symfony, Slim, Leaf or a plain composer setup and adapts. Coming from Laravel? There's a [dedicated section](#alchemy-in-a-laravel-project) on how Alchemy fits around Pint, Larastan and the rest of your existing setup.
+
+## How Alchemy works
+
+Before any config reference, here is the mental model. It is small:
+
+1. **`alchemy.yml` is your QA policy.** Each tool gets a section: `tests`, `lint`, `refactor`, `analyse`, `actions`. A section existing means "I want this"; a missing section means the tool never runs or installs.
+2. **Nothing installs until you use it.** Requiring Alchemy adds nothing else to your dependency tree. Pest arrives the first time you run your tests, Rector the first time you refactor, and always at the newest version your PHP supports.
+3. **Real config is generated per run, then discarded.** When you run a command, Alchemy translates your yml into the tool's native config inside `.alchemy/`, runs the tool, and throws the config away. Your project root is never written. Only engine caches stick around, and `.alchemy` is gitignored for you.
+4. **You can always leave.** `alchemy eject` exports real config files and rewires your composer scripts to call the engines directly. Your tests were plain Pest or PHPUnit tests all along.
+
+That's it. Everything below is detail on top of these four ideas.
## Setting up
-Leaf CLI will always ask if you want to add Alchemy to your project when you create a new project. If you already have a project and want to add Alchemy, you can do so by running the following command:
+Leaf CLI will ask if you want Alchemy when you create a new project. To add it to an existing project:
::: code-group
@@ -20,83 +33,52 @@ composer require leafs/alchemy --dev
:::
-Once installed, you need to run the setup command to configure Alchemy for your project.
+Then initialize it:
```bash:no-line-numbers
-./vendor/bin/alchemy install
+./vendor/bin/alchemy init
```
-This will automatically set up an `alchemy.yml` file in your project's root which you can use to configure your tests, linting and github actions. It also sets up commands for testing and linting in your `composer.json` file.
+`init` looks at your project before writing anything. It detects your framework, picks up the test engine you already use, and writes an `alchemy.yml` to match. If it finds existing tool configs (a `phpunit.xml`, a phpstan neon, a `rector.php`), it asks one question per file: **port it into `alchemy.yml`, or keep it?**
-## Configuring Alchemy
+- **Port** translates the config into the yml, suites and rules and all. The original file is parked at `.alchemy/.bak`, so your project root is clean with no extra steps.
+- **Keep** records the file in your `alchemy.yml`, and Alchemy runs that tool from your file, as-is, forever. More on this [below](#using-your-own-config-files).
-The `alchemy.yml` file should look something like this:
-
-```yaml [alchemy.yml]
-app:
- - app
- - src
+The file `init` writes covers the whole pipeline: tests, lint, analyse, refactor and CI. That's because a section's presence is what opts a tool in. Don't want one? Delete its section and that tool never runs or installs.
-tests:
- engine: pest
- parallel: true
- paths:
- - tests
- files:
- - '*.test.php'
- coverage:
- local: false
- actions: true
-
-lint:
- preset: PSR12
- rules:
- no_unused_imports: true
- not_operator_with_successor_space: false
- single_quote: true
-
-actions:
- run:
- - lint
- - tests
- os:
- - ubuntu-latest
- php:
- extensions: json, zip, dom, curl, libxml, mbstring
- versions:
- - '8.3'
- events:
- - push
- - pull_request
-```
+Prefer no prompts? `alchemy init --port` or `--keep` answers for every file at once. Either way, `init` also wires the commands below into your `composer.json`.
-You can make edits to this file to suit your needs. The `app` key is an array of directories to look for your app files in. The `tests` key is an array of configurations for your tests. The `lint` key is an array of configurations for your code styling checks. Once you're done setting up your `alchemy.yml` file, you can run your test command, lint command, GitHub actions command or the alchemy command to do all of that at once.
+## Everyday commands
::: code-group
```bash:no-line-numbers [Leaf CLI]
-leaf run test # Generate/Run tests
-leaf run lint # Generate/Run code styling checks
-leaf run actions # Generate GitHub Actions
-leaf run alchemy # Run all of the above
+leaf run test # run your tests
+leaf run lint # check code style (changes nothing)
+leaf run fmt # fix code style
+leaf run refactor # apply Rector refactors
+leaf run analyse # run PHPStan static analysis
+leaf run ci # generate CI pipelines
+leaf run alchemy # run everything at once
```
```bash:no-line-numbers [Composer]
-composer run test # Generate/Run tests
-composer run lint # Generate/Run code styling checks
-composer run actions # Generate GitHub Actions
-composer run alchemy # Run all of the above
+composer run test # run your tests
+composer run lint # check code style (changes nothing)
+composer run fmt # fix code style
+composer run refactor # apply Rector refactors
+composer run analyse # run PHPStan static analysis
+composer run ci # generate CI pipelines
+composer run alchemy # run everything at once
```
:::
-## Configuring Tests
-
-Alchemy uses Pest for testing by default. Pest is a delightful PHP Testing Framework with a focus on simplicity which matches Leaf's philosophy. We are working on adding support for PHPUnit as well.
+One split worth internalizing early: `lint` only reports and exits non-zero when style is off, which is what CI needs. `fmt` is the command that rewrites your files. `refactor` follows the same idea with a `--check` flag for CI.
-
+## Testing
-By default Pest expects a `phpunit.xml` file in your project root, but as it's quite annoying to read, Leaf provides a `alchemy.yml` file in your project root. This file is used to configure Pest and is much easier to read and understand. The `alchemy.yml` file is used to configure Pest and can be used to set up your test environment.
+A minimal setup is two sections: where your code lives, and how to test it.
```yaml [alchemy.yml]
app:
@@ -104,125 +86,300 @@ app:
- src
tests:
- engine: pest
+ engine: pest # or phpunit
parallel: true
paths:
- tests
files:
- '*.test.php'
- coverage:
- local: false
- actions: true
```
-- `app`: This is a list of directories that contain your application code. Alchemy will use these directories to lint your code and also in code coverage reports. If you want to use the root directory, you can just remove the entire `app` section.
+`app` lists the directories with your application code. Coverage uses it, and lint, refactor and analyse default to it too, so you only say it once.
+
+Inside `tests`:
-- `tests.engine`: The testing engine to use. Only Pest is supported engine at the moment, but we plan to add support for other engines in the future.
+- `engine`: `pest` or `phpunit`. Alchemy installs your pick on the first run. Parallel mode uses Pest's built-in runner, or paratest for PHPUnit.
+- `paths` and `files`: where tests live and what they are called. The defaults are `tests/` and `*.test.php`.
+- `flags`: standing flags passed to the engine on every run. Any Pest or PHPUnit option works. This is also where Pest 5's new toys live:
-- `tests.parallel`: Whether to run tests in parallel. This can speed up your test suite significantly.
+```yaml [alchemy.yml]
+tests:
+ engine: pest
+ flags:
+ - tia # Pest 5 Test Impact Analysis: only re-run tests affected by your changes
+```
-- `tests.paths`: The directories to look for tests in.
+For a one-off run, pass flags on the command line instead: `composer run test -- --flags=tia`.
-- `tests.files`: The files to look for tests in.
+::: details The full phpunit.xml, without the XML
+Everything you would normally reach into `phpunit.xml` for maps into the `tests` section: named suites, per-suite patterns and excludes, env/ini values, coverage excludes, and any root phpunit attribute passed through verbatim via `config`.
-- `tests.coverage`: Configuration for code coverage.
- - You can configure `local` to generate code coverage reports locally. By default, Alchemy will generate code coverage reports only on GitHub Actions.
- - You can also set `include` to include specific directories in your code coverage report. By default Alchemy will just use the directories defined in the `app` configuration.
+```yaml [alchemy.yml]
+tests:
+ engine: pest
+ suites:
+ Unit:
+ paths:
+ - tests/unit
+ Feature:
+ paths:
+ - tests/feature
+ files:
+ - '*Test.php'
+ exclude:
+ - tests/feature/legacy
+ config: # any phpunit.xml attribute, passed through as-is
+ stopOnFailure: true
+ executionOrder: random
+ env:
+ APP_ENV: testing
+ DB_DATABASE: ':memory:'
+ ini:
+ memory_limit: 512M
+ coverage:
+ exclude:
+ - src/legacy
+```
-If you don't want code coverage reports, you can just remove the entire `coverage` section.
+:::
-## Code Styling
+### Using your own config files
-Alchemy allows you to define code styling rules in your `alchemy.yml` file. Alchemy linting uses PHP CS Fixer which is a powerful tool that fixes your code to follow standards; whether you want to follow PHP coding standards as defined in the PSR-1, PSR-2, etc. Of course, all of this is abstracted into the beautiful `alchemy.yml` file.
+Any tool section can point at a file instead of holding a map. This is what a "keep" answer during `init` records, and you can write it yourself:
```yaml [alchemy.yml]
-app:
- - app
- - src
+tests: phpunit.xml # run this tool from my file, as-is
+analyse: phpstan.dist.neon
+```
+
+A map section is Alchemy-managed (generated per run, discarded after). A string section runs the engine directly against your file, untouched. Because the choice lives in `alchemy.yml`, CI and every teammate get the same behavior. As a safety net, a tool with no section at all still runs against a matching config file it finds in your project.
+
+## Code style
-...
+Style checks run through PHP CS Fixer, configured from the `lint` section. Every rule from the [PHP-CS-Fixer Configurator](https://mlocati.github.io/php-cs-fixer-configurator/) works as-is:
+```yaml [alchemy.yml]
lint:
preset: PSR12
- ignore_dot_files: true
+ risky: false # risky fixes are on by default
+ exclude:
+ - legacy
rules:
+ single_quote: true
+ no_unused_imports: true
array_syntax:
syntax: short
- no_unused_imports: true
- single_quote: true
- ordered_imports:
- imports_order: null
- case_sensitive: false
- sort_algorithm: alpha
- ...
```
-As you see, you can set up your code styling rules in the `lint` section of the `alchemy.yml` file. All of [PHP-CS-Fixer Configurator](https://mlocati.github.io/php-cs-fixer-configurator/) rules are supported.
+Remember the split: `composer run lint` checks and fails, `composer run fmt` fixes. If you would rather have CI fix style *for* you, set `lint.autofix: true` and the generated GitHub workflow will commit fixes instead of failing (GitHub only).
+
+Just like the tests engine, the linter is swappable. Laravel projects usually already lint with [Pint](https://laravel.com/docs/pint), so `lint` accepts a `provider` key:
+
+```yaml [alchemy.yml]
+lint:
+ provider: pint # phpcsfixer is the default
+ preset: laravel
+```
+
+Pint's rules *are* PHP CS Fixer rules, so your `rules` and `exclude` entries carry over unchanged. Presets map automatically (`PSR12` becomes `psr12`, and so on), and Pint-only keys like `notPath` and `notName` pass through verbatim, so the section is never less expressive than a hand-written `pint.json`. `alchemy init` picks this for you: in a Laravel project it selects Pint with the `laravel` preset, and an existing `pint.json` ports into `alchemy.yml` completely.
+
+Pint's runtime flags work too. Forward anything with `--flags`:
+
+```bash:no-line-numbers
+composer run fmt -- --flags=dirty # only fix files with uncommitted changes
+```
+
+Laravel gets the same treatment on the analysis side: `composer run analyse` in a Laravel project installs [Larastan](https://github.com/larastan/larastan) and wires it in automatically, so PHPStan understands facades, Eloquent and container magic instead of drowning you in false positives.
+
+## Automated refactoring
+
+Alchemy manages [Rector](https://getrector.com) the same way, and a fresh `alchemy init` includes a `refactor` section with the safe starter sets (`dead-code`, `code-quality`, plus upgrade sets for your composer.json PHP version). `composer run refactor` installs Rector and applies them. Because Rector rewrites code, it only runs when this section exists. Deleting the section opts out entirely.
+
+```yaml [alchemy.yml]
+refactor:
+ php: '8.2' # upgrade sets targeting this PHP version (true = read from composer.json)
+ sets:
+ - dead-code
+ - code-quality
+ - type-declarations
+ skip:
+ - src/legacy
+```
+
+In CI, `composer run refactor -- --check` fails when refactors are pending, without changing anything.
+
+::: details All available sets and options
+All twenty of Rector 2's prepared sets are available, kebab-cased: `dead-code`, `code-quality`, `coding-style`, `type-declarations`, `type-declaration-docblocks`, `privatization`, `naming`, `named-args`, `instanceof`, `if`, `early-return`, `strict-booleans`, `carbon`, `rector-preset`, `phpunit-code-quality`, `phpunit-narrow-asserts`, `phpunit-mock-to-stub`, `doctrine-code-quality`, `symfony-code-quality`, `symfony-configs`.
-- `lint`: Configuration for code styling checks.
+Other keys: `paths` (defaults to your app directories), `import-names: true` (import FQCNs and drop unused imports), `fluent-new-line: true`, and `downgrade: '8.0'` to rewrite syntax down to an older PHP version.
+:::
-- `lint.preset`: The preset to use for code styling checks. You can use any of the presets available to PHP CS Fixer. The default is `PSR12`.
+## Static analysis
-- `lint.ignore_dot_files`: Whether to ignore dot files when linting.
+A fresh `alchemy init` includes an `analyse` section (level 5), and PHPStan is installed and configured on your first `composer run analyse`. Tune it however far you want to go:
-- `lint.ignore_vc_files`: Whether to ignore version control files when linting.
+```yaml [alchemy.yml]
+analyse:
+ level: 6 # 0 (loose) to 10 (strict)
+ ignore:
+ - '#some error pattern to ignore#'
+```
-- `lint.parallel`: Whether to run linting in parallel. This can speed up your linting significantly.
+Analysis is check-only by nature: it exits non-zero when it finds problems, locally and in CI.
-- `lint.rules`: An array of rules to use for code styling checks. These rules are the same as the rules available in PHP CS Fixer.
+Two things happen for you automatically. A `phpstan-baseline.neon` at your project root is included if present (or point `analyse.baseline` elsewhere). And on Pest projects, when your analyse paths cover your tests, Alchemy installs [Pest's first-party PHPStan plugin](https://pestphp.com/docs/pest5-now-available) and wires it in, so `it()`, `expect()` and Pest's `$this` binding analyse cleanly.
-## Configuring GitHub Actions
+::: details Any phpstan parameter works
+Any key under `analyse` that Alchemy doesn't recognize is passed through to phpstan verbatim, so the section is never less expressive than a hand-written neon file:
+
+```yaml [alchemy.yml]
+analyse:
+ level: 8
+ includes:
+ - vendor/phpstan/phpstan/conf/bleedingEdge.neon
+ excludePaths:
+ - tests
+ treatPhpDocTypesAsCertain: false
+```
+
+:::
-Alchemy can also set up GitHub Actions for you. You can configure what it should generate in the `alchemy.yml` file. Once you have set up your `alchemy.yml` file, you can run the `alchemy` command to generate the GitHub Actions files.
+## Continuous integration
-```yaml
+The `actions` section describes what CI should run, and where. Alchemy generates pipelines for one or more providers from the same configuration:
+
+```yaml [alchemy.yml]
actions:
+ provider: github # or gitlab, circleci, or a list of them
run:
- lint
- tests
- os:
- - ubuntu-latest
- - windows-latest
- - macos-latest
+ - analyse
php:
- extensions: json, zip
versions:
+ - '8.2'
- '8.3'
events:
- push
- pull_request
```
-- `actions`: Configuration for GitHub Actions. You can remove this entire section if you don't want to generate GitHub Actions.
+`composer run ci` writes `.github/workflows/*.yml`, `.gitlab-ci.yml`, or `.circleci/config.yml` depending on your providers. Lint, refactor and analyse jobs all run in check mode: CI gates your code, it never rewrites it. GitHub configs also take `os` for a runner matrix, and `php.extensions` for extensions.
-- `actions.run`: An array of commands to generate GitHub Actions for. The default is `lint` and `tests`, but you can remove any command you don't want to generate.
+Generated CI files carry a `# Generated by Leaf Alchemy` header and are regenerated on every run, so action versions and pipeline fixes stay current when you update Alchemy. Remove the header from a file to take ownership, and Alchemy will never touch it again.
-- `actions.fail-fast`: Whether to stop the workflow as soon as one of the jobs fails.
+Moving CI providers is one command, because everything is generated from the same yml:
-- `actions.os`: The operating system to run the GitHub Actions on. The default is `ubuntu-latest`, but you can set `windows-latest` or `macos-latest` or all of them.
+```bash:no-line-numbers
+./vendor/bin/alchemy switch gitlab --clean
+```
-- `actions.php`: Configuration for PHP in GitHub Actions. You can set the PHP extensions to install and the PHP versions to test against.
+This updates your config, generates the new provider's pipeline, and removes the old provider's files (`--clean`). The same command switches test engines: `alchemy switch phpunit`.
-- `actions.events`: An array of events to generate GitHub Actions for. The default is `push` and `pull_request`, but you can remove any event you don't want to generate.
+## Alchemy in a Laravel project
-
+This exports your configuration to a standard `phpunit.xml` and `.php-cs-fixer.dist.php` (or `pint.json` when your provider is Pint), points your composer `test`/`lint` scripts directly at the engines, and tells you how to remove Alchemy. Your tests don't change. They were always plain Pest/PHPUnit tests.
diff --git a/src/index.md b/src/index.md
index 2045dbcc..023eaad4 100644
--- a/src/index.md
+++ b/src/index.md
@@ -1,6 +1,6 @@
---
layout: home
-title: Elegant PHP Built for Makers
+title: Build Products at the Speed of Thought
---
-
-
-
-
-
-
- Use Leaf as a lightweight micro-framework with no structure to build simple applications and APIs.
-
-
-
+
+
+
+
A working PHP app before the idea gets cold.
+
Start with routing and HTTP helpers, then add only what the product asks for. The project stays readable to you, your team, and your AI tools from the first file.
+
+
+
+ terminal
-
+
$ leaf create my-app --lite
$ cd my-app
$ leaf serve
+
-
-
-Micro-frameworks are lightweight, minimal, and focusedβgiving you just what you need to build fast without the overhead of a full-stack framework. Leaf is built for simplicity, speed, and ease of use, while offering more functionality than most micro-frameworks.
-
-Unlike Leaf MVC, using Leaf as a micro-framework gives you complete flexibility. There's no enforced structure, so you can build your app however you likeβperfect for small projects and APIs that donβt need strict separation of concerns.
-
-## Getting started
-
-You can create a new Leaf app using the `create` command on the Leaf CLI.
-
-```bash:no-line-numbers [Leaf CLI]
-leaf create my-app --basic
-```
-
-::: details Installing without the CLI
-
-If you don't have the Leaf CLI installed, you can install Leaf using Composer.
-
-```bash:no-line-numbers
-composer require leafs/leaf
-```
-
-You can then create a new Leaf app by creating a new `index.php` file and requiring the Leaf autoloader.
-
-```php:no-line-numbers [index.php]
-get('/', function() {
- response()->json(['message' => 'Hello, World!']);
-});
-
-app()->run();
-```
-
-:::
-
-Once you are in your application root, you can run the app using the `serve` command.
-
-```bash:no-line-numbers
-leaf serve
-```
-
-Your app is now running! Open `http://localhost:5500` in your browser.
-
-## Building your first app
-
-Now the fun begins! π With Leaf as a micro-framework, you have the freedom to build your app your way. Define routes, return JSON for an API, or use Blade to render viewsβitβs all up to you.
-
-Hereβs a quick example of a simple JSON API:
-
-```php [index.php]
-get('/', function() {
- response()->json(['message' => 'Hello, World!']);
-});
-
-app()->run();
-```
-
-Itβs a start, but thereβs so much more to build! You can now expand your app by adding more routes and features.
-
-As a maker, the fastest way to get started is by building a Coming Soon, Early Access, or Pre-Launch page. This gives you something real to share while you buildβhelping you attract early users and generate interest.
-
-Letβs create a simple pre-launch page using Leaf as a micro-framework.
-
-### Setting up our views
-
-We've already seen how routes work by returning JSON in the previous example. Now, letβs render a view using Blade instead. Unlike Leaf MVC, Blade isnβt installed by default when using Leaf as a micro-frameworkβbut donβt worry, setting it up is quick and easy!
-
-::: code-group
-
-```bash:no-line-numbers [Leaf CLI]
-leaf install blade@v4
-```
+
+
You begin with
+
+
One clear entry pointNo directory tour before you can respond to a request.
+
Routing and HTTPThe essentials for pages, APIs, webhooks, and small tools.
+
A path to growInstall modules or move into MVC without changing ecosystems.
+
+
+
+
-```bash:no-line-numbers [Composer]
-composer require leafs/blade:v4
-```
+Open `http://localhost:5500`. Your app is live.
-:::
-
-Once Blade is set up, we can create a new view inside the views directory at the root of your app. By default, Leaf looks for views in this directory unless you configure a different path.
-
-```blade [views/prelaunch.blade.php]
-
-
-
-
-
- Coming Soon
-
-
-
-
Something amazing is coming soon!
-
Sign up to be the first to know when we launch.
-
-
-
-```
+## The smallest useful Leaf app
-Now that we have a simple pre-launch page with a form to collect email addresses, we just need to create a route that will render this view.
+A lite project starts with enough structure to handle real requests without putting a framework ceremony between you and the product.
-```php [index.php]
+```php
get('/', function() {
- response()->json(['message' => 'Hello, World!']);
-});
-
-app()->view('/prelaunch', 'prelaunch'); // [!code ++]
+app()->get('/', fn () => response()->json(['message' => 'Hello from Leaf']));
app()->run();
```
-Here's what's happening behind the scenes:
-
-- `app()` is a helper function that gives you access to the Leaf app instance. You can use it anywhere in your app.
-- `view()` is a method for defining a route that renders a Blade view. The first argument is the URL path, and the second is the name of the view file to load.
-
-Notice we did not have to configure Blade because Leaf does that for you. You can now navigate to `http://localhost:5500/prelaunch` to see your pre-launch page.
-
-### Handling the form submission
-
-Now that we have a pre-launch page, we need to handle form submissions. Letβs create a new route to process the form and save the email to a database.
-
-```php [index.php]
-app()->view('/prelaunch', 'prelaunch');
-app()->post('/store', function () { // [!code ++]
- // handle the email // [!code ++]
-}); // [!code ++]
-```
-
-We used the `post()` method to ensure only POST requests reach this route. The second argument is a function where we'll handle the email. Leaf makes validation simpleβwe can call `validate()` on the request and define our validation rules. Letβs validate and store the email.
-
-```php [index.php]
-app()->post('/store', function () {
- if (!$data = request()->validate(['email' => 'email'])) { // [!code ++]
- // validation failed, redirect back with errors // [!code ++]
- return response() // [!code ++]
- ->withFlash('errors', request()->errors()) // [!code ++]
- ->redirect('/prelaunch'); // [!code ++]
- } // [!code ++]
-
- // save the email
-});
-```
-
-Thatβs it for validation! But before we can save the email, we need to connect our database to the application. Letβs set that up next.
-
-### Setting up our database
-
-We can set up our database by installing the database module and configuring our credentials. Just like we installed Blade earlier, weβll install the database module the same way.
-
-::: code-group
-
-```bash:no-line-numbers [Leaf CLI]
-leaf install db
-```
-
-```bash:no-line-numbers [Composer]
-composer require leafs/db
-```
-
-:::
-
-Now we just need to fill out our database information:
-
-```php [index.php]
-connect([ // [!code ++]
- 'host' => '127.0.0.1', // [!code ++]
- 'username' => 'root', // [!code ++]
- 'password' => '', // [!code ++]
- 'dbname' => 'Leaf', // [!code ++]
-]); // [!code ++]
-
-app()->get('/', function() {
- response()->json(['message' => 'Hello, World!']);
-});
-
-app()->view('/prelaunch', 'prelaunch');
-
-app()->post('/store', function () {
- if (!$data = request()->validate(['email' => 'email'])) {
- // validation failed, redirect back with errors
- return response()
- ->withFlash('errors', request()->errors())
- ->redirect('/prelaunch');
- }
-
- // save the email
-});
+
+
+ 01 / Route
+ Match the request
+ Connect an HTTP method and URL to the behavior your application needs.
+
+
+ 02 / Logic
+ Write the useful part
+ Keep the first version close to the request, then extract structure as complexity earns it.
+
+
+ 03 / Response
+ Return clean output
+ Send JSON, HTML, redirects, downloads, or any response the client expects.
+
+
-app()->run();
-```
+## Build with an assistant that knows the app
-::: details Creating your database
+This app was created with Leaf CLI, so `.leaf/CONTEXT.md` is already available as shared project memory. A local agent reads it alongside the filesystem and syncs useful changes back when it finishes. No context command is required.
-Unlike with Leaf MVC, Leaf as a micro-framework does not come with a database migration system. You will have to create your database manually. You can do this using a tool like [phpMyAdmin](https://www.phpmyadmin.net/), TablePlus or the command line.
+If you are using an external assistant that cannot access the folder, print a compact project handoff:
-```sql:no-line-numbers
-CREATE DATABASE Leaf;
+```bash:no-line-numbers
+leaf context
```
-:::
-
-Once you have filled out your database information, you can now save the email to the database.
-
-### Saving the email
-
-We can now save the email to the database.
+
+
+
+
Your prompt
+
Add a Stripe webhook, validate the event, and store successful payments.
+
Local agent: shared context and filesystem access stay in sync. External assistant: paste the output of leaf context so it receives the compact project map.
+
+
+
AI sees
+
entry: lite
routes: known
modules: known
patterns: Leaf
+
+
+
-```php [index.php]
-connect([
- 'host' => '127.0.0.1',
- 'username' => 'root',
- 'password' => '',
- 'dbname' => 'Leaf',
-]);
+Leaf modules add focused features without replacing your starting point.
-app()->get('/', function() {
- response()->json(['message' => 'Hello, World!']);
-});
+```bash:no-line-numbers
+leaf install auth db mail
+```
+
+
-app()->view('/prelaunch', 'prelaunch');
+Once installed, modules use the same concise Leaf style:
+
+```php
+auth()->login($credentials);
+
+$user = db()
+ ->select('users')
+ ->where('email', $email)
+ ->first();
+
+mailer()
+ ->to($user->email)
+ ->send('welcome');
+```
+
+## Grow at your own pace
+
+You do not need to predict the final architecture on day one. Choose the amount of structure the product needs now.
+
+
+
+ 01 / Lite
+ Prove the idea
+ Use a small entry point for scripts, experiments, webhooks, APIs, and focused tools.
+
+
+ 02 / Modules
+ Add capabilities
+ Bring in authentication, data, mail, billing, queues, or caching as requirements appear.
+
+
+ 03 / MVC
+ Organize the product
+ Move into controllers, models, views, services, and conventions when the team or app needs them.
+
+
-app()->post('/store', function () {
- if (!$data = request()->validate(['email' => 'email'])) {
- // validation failed, redirect back with errors
- return response()
- ->withFlash('errors', request()->errors())
- ->redirect('/prelaunch');
- }
+All three stages stay inside Leaf, so growth does not require a framework rewrite.
- db()->insert('emails')->params($data)->execute(); // [!code ++]
+## Deploy anywhere PHP runs
- return response() // [!code ++]
- ->withFlash('success', 'You have been added to our list!') // [!code ++]
- ->redirect('/prelaunch'); // [!code ++]
-});
+Leaf has no private runtime or hosting lock-in. Deploy to a VPS, shared hosting, containers, or a managed PHP platform using the same application you built locally.
-app()->run();
-```
-
-You can use the `withFlash()` method to send a message to the next request. This is useful for sending messages to the user after a redirect. We can now test our app by navigating to the `/prelaunch` page and submitting an email.
-
-### Deploying your app
-
-We have built a simple pre-launch page using Leaf. You can now deploy your app to a server using a service like [Heroku](/learn/deployment/heroku/), [Fly.io](/learn/deployment/flyio/) a VPS like [DigitalOcean](/learn/deployment/digitalocean/), or even a shared hosting service like [Sevalla](/learn/deployment/sevalla/).
-
-
-
-
-
-
-
-
-
-
-
-
- Are you stuck?
- Leaf as a micro-framework is a great way to build simple applications and APIs quickly, but doesn't provide the structure you get with Leaf MVC. If you are stuck at any point, feel free to ask for help in the
- Leaf Discord server, or consider building an MVP using Leaf MVC if you prefer a more structured approach.
-
-
-
+
+
+
When structure becomes useful
+ The same project can grow into Leaf MVC.
+ Keep the ecosystem, add the application map.
+
## What to read next
-Now that you have built a simple pre-launch page, the next step is to get you familiar with the basics of building a full-stack application with Leaf. So you can build and launch your next big idea *fast*.
-
-
diff --git a/src/learn/contributing.md b/src/learn/contributing.md
index b44e1e68..aae0703b 100644
--- a/src/learn/contributing.md
+++ b/src/learn/contributing.md
@@ -16,7 +16,9 @@ We are glad to have you
The Codelab section gives developers examples to work off of that both cover common or interesting use cases, and also progressively explain more complex detail. Our goal is to move beyond a simple introductory example, and demonstrate concepts that are more widely applicable, as well as some caveats to the approach.
-If you're interested in contributing, please initiate collaboration by filing an issue under the tag **`codelabs experiment`** with your concept so that we can help guide you to a successful pull request. After your idea has been approved, please follow the template below as much as possible. Some sections are required, and some are optional. Following the numerical order is strongly suggested, but not required.
+If you're interested in contributing, please initiate collaboration by filing an issue under the tag **`codelabs experiment`** with your concept so that we can help guide you to a successful pull request.
+
+After your idea has been approved, please follow the template below as much as possible. Some sections are required, and some are optional. Following the numerical order is strongly suggested, but not required.
Experiments should generally:
diff --git a/src/learn/deployment/flyio/index.md b/src/learn/deployment/flyio/index.md
index b76b6dfe..8d959fbb 100644
--- a/src/learn/deployment/flyio/index.md
+++ b/src/learn/deployment/flyio/index.md
@@ -1,58 +1,59 @@
-# Deploying a LeafMVC Application to Fly.io
+# Deploying to Fly.io
-::: warning Version support
-Version support. This tutorial assumes use of LeafPHP >= 3.0 and PHP >=7.0.
-:::
-
-## What Are We Building
-
-This experiment will guide you deploying your first LeafMVC / base Leaf application to Fly.io. A majority
-of the same steps apply to Leaf v3 core as well. This guide uses docker to deploy your application.
-You do not need to have docker installed on your local machine to follow this guide. Neither do you
-need to have prior knowledge of docker.
-
-::: details (New to Fly.io?)
-Fly.io transforms containers into micro-VMs that run on their hardware.
-:::
+Fly.io runs your app in small VMs close to your users, and it is the fastest way to get a Leaf app live from your terminal. Leaf CLI handles the whole flow with one command.
## Prerequisites
-This tutorial assumes you have the following:
+- A [Fly.io account](https://fly.io/app/sign-up)
+- The [fly CLI](https://fly.io/docs/flyctl/install/) installed and logged in (`fly auth login`)
-- A Leaf application
-- A Fly.io account
-- The [flyctl cli tool](https://fly.io/docs/hands-on/install-flyctl/) installed
+That's it. You don't need Docker installed or any Docker knowledge; Fly builds the image remotely.
-## 1. Set up docker in your Leaf application
+## Deploy
-You can clone the [Fly.io starter template](https://github.com/cr34t1ve/leaf-fly-io-template) to get started.
+From your app's root directory:
```bash
-git clone https://github.com/cr34t1ve/leaf-fly-io-template.git
+php leaf deploy
```
-This template has a `Dockerfile` and a `fly.toml` file already set up for you.
+On the first run, this:
-## 2. Deploy your application
+1. Writes the deployment files into your project (a production `Dockerfile`, `fly.toml` and the server config they need)
+2. Creates the app on Fly and deploys it
-Navigate to your application's root directory and run the following command:
+Your app name comes from `APP_NAME` in your `.env`, and the region from `APP_PROD_REGION` (defaulting to `iad`). You can override both:
```bash
-fly deploy
+php leaf deploy --name my-unique-app --region lhr
```
-This command will build your docker image and deploy it to Fly.io.
+::: details App names are global
+Fly app names are unique across all of Fly, not just your account. If your name is taken, the deploy fails with a message telling you to pick another with `--name`.
+:::
+
+Running `php leaf deploy` again after the first deploy ships your latest changes to the existing app. The generated Dockerfile also builds your JavaScript assets (Vite, Inertia and friends), so there is no separate build step.
+
+::: details Single-file apps work too
+If your app is a single `index.php` at the project root rather than an MVC app with a `public` directory, the generated config serves it from the right place automatically. Your `.env`, `vendor` directory and composer files stay unreachable from the browser either way.
+:::
+
+## Production secrets
-After setting up your application, you can then run the following to launch your application:
+Your `.env` file is never uploaded with your app. After deploying, the CLI lists the keys your app likely needs in production and prints the command to set them:
```bash
-fly launch
+fly secrets set APP_KEY= DB_PASSWORD=
```
-You can then visit your application at the URL provided.
+Fill in the values and your app restarts with them available as environment variables.
-## Conclusion
+## Useful follow-ups
-You have successfully deployed your LeafMVC application to Fly.io. You can now scale your application
+```bash
+fly logs # tail your app's logs
+fly status # see machine state
+fly scale count 1 --yes # keep one machine always running
+```
-Experiment by **[Desmond Sofua](https://github.com/cr34t1ve)**
+By default, Fly stops machines when idle and starts them on the next request. Keeping one machine running avoids cold starts on low-traffic apps.
diff --git a/src/learn/deployment/heroku/index.md b/src/learn/deployment/heroku/index.md
index c628ff0c..c31dda4c 100644
--- a/src/learn/deployment/heroku/index.md
+++ b/src/learn/deployment/heroku/index.md
@@ -38,7 +38,9 @@ For most use-cases, you would usually push your project to GitHub, then connect
To get started, you will need to make sure you are logged in to Heroku. You can do this by running `heroku login`. You will be prompted to login in your browser. Once you have logged in, you can proceed.
-Heroku uses git to deploy your application, so you will need to initialize git in your repository. You can do this by running `git init` in your project directory. After initializing git, you can add your files to the staging area by running `git add .`. Once you have added your files, you can commit them by running `git commit -m "commit message"`.
+Heroku uses git to deploy your application, so you will need to initialize git in your repository. You can do this by running `git init` in your project directory.
+
+After initializing git, you can add your files to the staging area by running `git add .`. Once you have added your files, you can commit them by running `git commit -m "commit message"`.
## 3. Deploying your app
diff --git a/src/learn/deployment/index.md b/src/learn/deployment/index.md
index dceabb88..e192ea0d 100644
--- a/src/learn/deployment/index.md
+++ b/src/learn/deployment/index.md
@@ -1,19 +1,19 @@
# Deployment
-Getting your Leaf app live should be as simple as building it. Whether you're deploying to shared hosting, VPS, or platforms like DigitalOcean and Vercel, Leaf makes the process smooth and hassle-free. This guide walks you through setting up your server, configuring URL rewriting, and making sure your app runs efficiently in production.
+Getting your Leaf app live should be as simple as building it. This guide walks you through setting up your server, configuring URL rewriting, and making sure your app runs well in production, whether you're deploying to shared hosting, a VPS, or a platform like DigitalOcean or Vercel.
## Production Checklist
Before deploying your app, make sure youβve covered the following:
- **[Environment Variables](/docs/config/environment)**: Set up your environment variables for production.
-- **[Debug Mode](/docs/routing/error-handling)**: Turn off debug mode and disable Leaf DevTools.
+- **[Debug Mode](/docs/routing/error-handling)**: Turn off debug mode so errors aren't rendered to your users.
These are meant to ensure your app runs smoothly in production, without exposing sensitive information or running unnecessary debugging tools.
## URL Rewriting
-URL rewriting maps all requests to a single entry pointβusually `index.php`βso Leafβs router can handle them dynamically. Instead of serving files directly, web servers like Apache and Nginx can be configured to route all traffic through your app, so Leaf can handle requests cleanly and efficiently.
+URL rewriting maps all requests to a single entry point (usually `index.php`) so Leafβs router can handle them dynamically. Instead of serving files directly, web servers like Apache and Nginx can be configured to route all traffic through your app, so Leaf can handle every request.
::: code-group
@@ -32,15 +32,29 @@ RewriteRule . index.php [L]
Without this, things like routing, request handling, and error pages wonβt work as expected. Make sure to set up URL rewriting correctly on your server to ensure your Leaf app runs smoothly.
+## One-command deploys
+
+For Fly.io and Render, Leaf CLI prepares (and where possible runs) the whole deployment for you:
+
+```bash
+php leaf deploy # deploy to Fly.io
+php leaf deploy --to render # prepare a Render deployment
+```
+
+The command writes a production Dockerfile plus the provider's config into your project. It works for both full Leaf MVC apps and single-file Leaf apps, pointing the web server at the right place for each and keeping your `.env`, `vendor` and composer files out of the browser's reach. Because the Dockerfile is provider-agnostic, you can also take it to any other platform that deploys Docker images.
+
+See the guides below for the details of each provider.
+
## Deployment Guides
Okay, now letβs get your app live! π
| Provider | Description |
| :-------------------------------------------------------------- | :--------------------------------------------------------- |
+| [Fly.io](/learn/deployment/flyio/) | One-command deploys with `php leaf deploy` |
+| [Render](/learn/deployment/render/) | Free-plan git-based deploys, prepared by the Leaf CLI |
| [Digital Ocean](/learn/deployment/digitalocean/) | Deploying LeafMVC projects to a new Digital Ocean droplet |
| [Heroku](/learn/deployment/heroku/) | Deploying a base Leaf project to Heroku using the Leaf CLI |
-| [Fly.io](/learn/deployment/flyio/) | Deploying a base Leaf application to Fly.io |
## Deploying Vite/Inertia Apps
@@ -62,8 +76,12 @@ pnpm run build
:::
+::: warning Build before you deploy
+
If you don't build your assets before deploying, you will either have a fully broken app or a CORS error in the case of Inertia.js, so make sure to build your assets before deploying or add it to your deployment script.
+:::
+
## Deploying Queues/Workers
When deploying your application with [queues](/docs/utils/queues), Leaf takes care of setting up the necessary files and commands based on your chosen queue driver. However, once deployed, youβll need to set up your server to keep your workers running continuously.
@@ -74,7 +92,9 @@ For smaller applications, you can keep the queue worker running in the backgroun
php leaf queue:work &
```
-This command will set up your queue and start a worker to process jobs. Leaf includes safeguards to prevent excessive memory usage, long-running processes, or crashes from failed jobs. However, for larger applications, this setup may not be enough. In such cases, using a process manager like Supervisor is recommended to ensure your workers run smoothly and restart automatically if needed:
+This command will set up your queue and start a worker to process jobs. Leaf includes safeguards to prevent excessive memory usage, long-running processes, or crashes from failed jobs.
+
+However, for larger applications, this setup may not be enough. In such cases, using a process manager like Supervisor is recommended to ensure your workers run smoothly and restart automatically if needed:
```bash:no-line-numbers
sudo apt update && sudo apt install supervisor -y
diff --git a/src/learn/deployment/render/index.md b/src/learn/deployment/render/index.md
new file mode 100644
index 00000000..6d2c9e88
--- /dev/null
+++ b/src/learn/deployment/render/index.md
@@ -0,0 +1,49 @@
+# Deploying to Render
+
+Render can host a Leaf app on its free plan, deploying automatically from your git repository. Leaf CLI prepares everything Render needs; you connect the repository once and every push deploys.
+
+## Prerequisites
+
+- A [Render account](https://dashboard.render.com/register)
+- Your app in a git repository on GitHub or GitLab
+
+## Prepare your app
+
+From your app's root directory:
+
+```bash
+php leaf deploy --to render
+```
+
+This writes two things into your project:
+
+- A production `Dockerfile` (shared with the Fly setup, it also builds your JavaScript assets)
+- A `render.yaml` blueprint describing your service: docker runtime, free plan and a health check
+
+Render picks its default region unless you choose one (`oregon`, `virginia`, `ohio`, `frankfurt` or `singapore`):
+
+```bash
+php leaf deploy --to render --region frankfurt
+```
+
+Commit and push them:
+
+```bash
+git add . && git commit -m "add render deployment files" && git push
+```
+
+## Connect the repository
+
+1. Open [dashboard.render.com](https://dashboard.render.com)
+2. Click **New** β **Blueprint**
+3. Connect your repository
+
+Render reads `render.yaml` and creates the service. From here on, every push to your default branch deploys automatically.
+
+## Production secrets
+
+Your `.env` file is never uploaded. The CLI lists the keys your app needs in production; add them under your service's **Environment** tab in the Render dashboard. `APP_ENV` and `APP_DEBUG` are already set by the blueprint.
+
+::: details About the free plan
+Free Render services sleep after 15 minutes without traffic and wake on the next request, which takes a few seconds. That's fine for side projects and demos; upgrade the `plan` in `render.yaml` when you need an always-on app.
+:::
diff --git a/src/learn/index.md b/src/learn/index.md
index 1f04eec0..b0927798 100644
--- a/src/learn/index.md
+++ b/src/learn/index.md
@@ -6,24 +6,24 @@ next: false
- Learn to write
- Elegant PHP
- Built for Makers
+ Build with Leaf
+ Build faster.
+ Ship Sooner.
-Welcome to the Makers' Guide to Leafβyour fast track to building with Leaf. This guide walks you through everything you need to start shipping quickly and efficiently, from hello world to deploying your app.
+Leaf is designed to help you go from idea β working app β real users, without getting stuck in setup or complexity.
+
+This is where you start, whether it's your first tool or your next startup.
## Choose your path
βNo application is the same, why should your framework be?β
Michael Darko
Creator of Leaf PHP
-This is something we live and die by at Leaf. We believe that every application is unique and should be treated as such. That's why we've built Leaf to be as flexible as possible, allowing you to build your applications the way you want to. At the end of the day, we're here to help you build your applications, not to dictate how you should build them.
-
-With that in mind, we've created a few paths to help you get started with Leaf. Whether you're building a simple app/API, a full-fledged web application, or a massive API, we've got you covered. Choose your path below to get started.
+This is something we live and die by at Leaf. We believe that every application is unique and should be treated as such. That's why we've built Leaf to be as flexible as possible, allowing you to build your applications the way you want to. Pick what you want to build:
@@ -37,16 +37,19 @@ With that in mind, we've created a few paths to help you get started with Leaf.
class="sm:max-w-sm sm:flex-none md:w-auto md:flex-auto flex flex-col items-start relative z-10 p-6 xl:p-8"
>
- Basic Leaf App
+ Micro-tool
-
- Use Leaf as a micro-framework to build simple apps and APIs.
+
+ Start simple. Ship fast
+
+
+ Perfect for ideas, small tools, experiments
Start simple
@@ -21,11 +21,8 @@ import TutorialNumber from '@theme/components/shared/TutorialNumber.vue';
-
- Leaf MVC is a minimalistic PHP framework built for developers who need a simple and elegant toolkit to create full-featured web applications.
+ Turn your idea into a real product
`). Never remove or edit it.
+- Sections are `##` headings in their existing order. Preserve sections you don't recognize β another agent may own them. Add project-specific sections at the end only.
+- A line wrapped in underscores is a placeholder. If it contains `agent:`, it is an instruction to you: act on it, then replace the line with real content (or delete it).
+- Keep entries one line each where possible so concurrent agents' edits merge cleanly.
+- Never store secrets or tokens; refer to `.env` keys by name only.
+- Never duplicate mechanical info (routes, models, modules, structure) β that lives in code and `leaf context`. The file holds what code cannot say: goals, decisions, reasoning.
+
+**Write-back protocol (after completing work):**
+
+- Recent Changes: add `* YYYY-MM-DD β what changed (key files)`, newest first, five entries max; fold older entries into Known Decisions or delete them.
+- Current Goal: exactly one at a time. When it's done, note it in Recent Changes and replace it β ask the user if the next goal is unknown.
+- Known Decisions: record lasting choices as `* Decision β reasoning.` Always include the why, or the next agent will relitigate it.
+- Keep the file concise: summarize instead of appending, remove outdated lines, reference files instead of copying them.
+
+**Canonical sections** (the shipped template, in order β create missing files with these):
+
+```markdown
+
+
+## Working With This File (the format contract, keep as shipped)
+## Project Summary (2-3 lines: what the app is, for whom)
+## Current Goal (exactly one)
+## Architecture (entry point, key folders, notable wiring)
+## External Providers (services + env key NAMES, never values)
+## Coding Conventions (project-specific style the code can't show)
+## Recent Changes (dated one-liners, five max)
+## Known Decisions (decision + reasoning)
+## Future Ideas (parked, not committed)
+```
+
+No setup command is required for Leaf MVC or projects created through Leaf CLI. If a project has no `.leaf/CONTEXT.md`, offer to create one with these sections.
+
+For an external assistant without access to the project, use:
+
+```bash
+leaf context # prints a compact context handoff
+```
+
+This produces a minified view of the shared project context: routes, modules, config, structure, and conventions.
+
+**External workflow:**
+1. Run `leaf context`
+2. Paste the command output into your AI assistant
+3. Say what you want: *"Add billing"*, *"Create a dashboard"*
+4. AI has real context β stops guessing, builds correctly
+
+**If you are the assistant receiving a pasted handoff** (it starts with `# Leaf Context Handoff`):
+
+- The mechanical map (routes, modules, models, env key names) was scanned from the real code β trust it over your assumptions, and use the project's actual route and model names in everything you generate.
+- The "Shared memory" section carries the project's goals and decisions. Respect recorded decisions instead of proposing alternatives the team already rejected.
+- The handoff is read-only: you cannot write back to `.leaf/CONTEXT.md` from outside. When your work produces knowledge worth keeping (a new decision, a completed goal), end your reply with a short "for your `.leaf/CONTEXT.md`" note the user can paste in.
+- Ask the user for any file your change depends on that the handoff doesn't show β never guess at code you cannot see.
+
+The handoff is a portable snapshot. It is not the same as the two-way `.leaf/CONTEXT.md` used by agents inside the project.
+
+---
+
+## Installing Modules
+
+```bash
+leaf install auth # Authentication
+leaf install db # Database
+leaf install mail # Mailing
+leaf install cors # CORS
+leaf install fs # Filesystem (needed for file uploads in Basic apps)
+```
+
+Everything wires up automatically. No glue code.
+
+---
+
+## Reference Files
+
+Read the relevant file before generating code for that area:
+
+| Topic | File |
+|---|---|
+All reference files live at `https://leafphp.dev/ai/references/.md` β fetch them raw from there.
+
+| Lite apps: the manual setup contract (db, views, Vite, schema) | `references/lite.md` |
+| Routing (methods, groups, dynamic routes, constraints) | `references/routing.md` |
+| Middleware (closures, classes, `$next`, data passing) | `references/middleware.md` |
+| Request API (get, validate, upload, client info, metadata) | `references/request.md` |
+| Response API (json, views, redirects, headers, cookies) | `references/response.md` |
+| CORS (config, options, MVC vs Lite) | `references/cors.md` |
+| MVC (controllers, models, schema files, services, libraries, globals) | `references/mvc.md` |
+| Views (Blade, BareUI, Inertia, Vite, Tailwind) | `references/views.md` |
+| Advanced (cache, storage, queues, billing, i18n, sitemap) | `references/advanced.md` |
+| Mail, HTTP fetch client, HTTP cache | `references/mail-fetch-cache.md` |
+| Dates & time (tick(), timezones, formatting, diff) | `references/dates.md` |
+| Utilities (Anchor/XSS/SQL protection) | `references/utilities.md` |
+| Sessions, cookies, flash, validation, CSRF, passwords | `references/security.md` |
+| Auth (login, register, sessions, JWT, OAuth, scaffold) | `references/auth.md` |
+| Database (db(), query builder, transactions, Redis) | `references/database.md` |
+| App config, env, logging, DI, maintenance, deployment | `references/app-config.md` |
+
+---
+
+## Known Footguns
+
+Read this before writing code β each entry is a mistake real agents have made:
+
+- **Roles are additive.** `assign()` appends, it never replaces. To change a role: `$user->unassign($user->roles()); $user->assign($newRole);` (a one-shot `sync()` is planned). A "role change" via `assign()` alone silently keeps the old role.
+- **`$user->get()` hides `id`, `password`, and roles.** Use `$user->id()` and `$user->roles()`. API response shape: `[...$user->get(), 'roles' => $user->roles()]`.
+- **`text` validates letters and spaces ONLY.** For passwords and free-form input use `string`.
+- **Auth middleware defaults render HTML** (redirects, error pages). APIs must override each to JSON: `auth()->middleware('auth.required', fn () => response()->exit(['error' => 'Unauthorized'], 401));`
+- **`_env()` caches per process.** Runtime env changes need `_envUncached()`.
+- **`unique` config skips fields missing from the data** β a table without `username` is safe on defaults, but set `['email']` explicitly.
+- **`User` objects hydrate from a plain row array with no DB connection** β only mutating methods need `setDb()`. For bulk list endpoints, stay on raw rows to avoid N+1 hydration.
+- **Never call `app()->run()` in an MVC app.** Only lite apps run themselves.
+- **Auth, db(), and Eloquent models share one database connection in MVC** (leafs/db 5.1+): db() lazily borrows Eloquent's PDO, so an auth read and a model write can't deadlock each other. Keep the SQLite `journal_mode`/`busy_timeout` defaults in `config/database.php` anyway β they protect concurrent PHP processes.
+- **`date` and `timestamp` columns are stored as `YYYY-MM-DD HH:MM:SS`.** Compare with `whereDate()`, not `where()` β a string comparison against a bare `YYYY-MM-DD` treats an exact boundary date as greater-than and quietly returns wrong rows.
+- **Inertia auto-shares an `auth` prop** (`{id, user, roles, permissions, errors}`) on every page, and the framework's value wins. Don't `Inertia::share()` your own `auth` key. `user` contains every non-hidden column.
+- **To run application code headlessly** (scripts, diagnosis), `require vendor/autoload.php` then `\Leaf\Core::loadApplicationEnv()` and `\Leaf\Core::loadApplicationConfig()` β `leaf interact` is an interactive REPL and can't be scripted.
+- **"Add React/Vue/Svelte" does not mean "build a separate SPA."** When a user asks for a JS frontend in an existing Leaf app, reach for `leaf view:install` and the Inertia bridge. Never scaffold a standalone SPA with its own hand-written JSON API unless the user explicitly asks for a separate frontend β agents with SPA priors rebuild the bridge from scratch and lose auth sharing, routing, and validation for free.
+- **Fetch this skill and its reference files RAW** (curl or an equivalent that returns the file verbatim). Summarizing fetch tools have been observed inventing plausible-but-nonexistent Leaf APIs (usually Laravel-shaped ones) that then fail at runtime. If you can only see a summary, treat any API you haven't seen verbatim as unverified.
+
+---
+
+## When Helping a User Build with Leaf 5
+
+1. **Read `.leaf/CONTEXT.md` first** if shared β reveals entry point, routes, installed modules
+2. **Fetch the reference file for every area you touch β especially the areas you think you already understand.** The most expensive agent mistakes on record came from skipping a reference that contradicted a prior: generic PHP+React knowledge says "build a SPA against a JSON API" while `views.md` says use the Inertia bridge; generic knowledge guesses at upload shapes that `request.md` states exactly. Fetching only the references that confirm your plan is the failure mode β fetch the one that could veto it. In a lite app, read `lite.md` before writing any wiring
+3. **Prefer Leaf functions over hand-rolled code, always.** Before writing any helper or custom logic, check in this order: a module method (reference files first), then `leaf install `, then a scaffold. Write custom code only when no Leaf API covers the need, and record why in `.leaf/CONTEXT.md` Known Decisions. Never reimplement hashing, validation, auth flows, or query building that Leaf modules provide
+4. **Inertia pages are kebab-case files in lowercase folders** β `views/js/pages/order-history.jsx`, rendered with `return response()->inertia('order-history', $props)`. Never PascalCase file or folder names (the component *inside* the file stays PascalCase per React convention), and prefer `response()->inertia()` over the bare `inertia()` helper
+5. **Return responses, prefer arrow functions** β `app()->get('/', fn () => response()->json([...]));` for single-expression handlers; in multi-statement closures and controllers, `return response()->...` as the final statement. Never call `response()` without returning it. The same shape renders frontend pages: `response()->inertia('home', ['cards' => $cards]);` returns a React/Vue/Svelte page
+6. **Respect the entry point** β don't impose MVC structure on a Basic app unless asked
+7. **Use their actual names** β route names, model names, controller names from their project
+8. **Favor simplicity** β that's the Leaf way
+
+---
+
+## Notes for Claude
+
+- Leaf 5 is the current major generation of Leaf PHP.
+- Creator: **mychidarko** (Michael Darko), founder of Leaf PHP
+- Backwards-compatible with Leaf 4 patterns where possible
+- If asked about undocumented features, ask the user β especially if talking to the creator
diff --git a/src/public/ai/references/advanced.md b/src/public/ai/references/advanced.md
new file mode 100644
index 00000000..717c3174
--- /dev/null
+++ b/src/public/ai/references/advanced.md
@@ -0,0 +1,494 @@
+# Leaf 5 β Advanced Features Reference
+
+## Application Cache
+
+```bash
+leaf install cache
+```
+
+File-based caching. No configuration needed.
+
+```php
+// Cache with auto-fetch (most common pattern)
+$data = cache('queries.complexQuery', 60 * 60, function () {
+ return db()->select('complex_table')->where('col', 'val')->get();
+});
+
+// Cache permanently (no duration)
+cache('settings.siteConfig', function () {
+ return db()->select('settings')->get();
+});
+
+// Retrieve only (returns null if not cached)
+$data = cache('queries.complexQuery');
+
+// Overwrite existing cache
+cache()->put('queries.complexQuery', $newData, 60 * 60);
+
+// Delete
+cache()->forget('queries.complexQuery');
+cache()->flush(); // clear all β use with caution
+```
+
+---
+
+## File Storage
+
+```bash
+leaf install fs
+```
+
+### File Paths
+
+```php
+$path = path('path/to/file.txt');
+$path->dirname(); // path/to
+$path->basename(); // file.txt
+$path->extname(); // txt
+
+path('path')->join('to', 'file.txt'); // path/to/file.txt
+path('path/to/parent/..//file.txt')->normalize(); // path/to/file.txt
+```
+
+### Files
+
+```php
+// Create
+storage()->createFile('path/to/file.txt');
+storage()->createFile('path/to/file.txt', 'Hello, world!');
+storage()->createFile('path/to/file.txt', function () { return 'Hello!'; });
+storage()->createFile('path/to/file.txt', 'content', ['overwrite' => true]);
+
+// Read
+$content = storage()->read('path/to/file.txt');
+
+// Update
+storage()->writeFile('path/to/file.txt', 'New content');
+storage()->writeFile('path/to/file.txt', function ($current) {
+ return $current . ' appended';
+});
+
+// File info
+$info = storage()->fileInfo('path/to/file.txt');
+$size = storage()->size('path/to/file.txt'); // bytes
+$type = storage()->type('path/to/file.txt');
+$lastModified = storage()->lastModified('path/to/file.txt');
+$ext = storage()->extension('path/to/file.txt'); // txt
+$base = storage()->basename('path/to/file.txt'); // file.txt
+$dir = storage()->dirname('path/to/file.txt'); // path/to
+
+// Exists / type checks
+storage()->exists('path/to/file.txt');
+storage()->isFile('path/to/file.txt');
+storage()->isFolder('path/to/folder');
+
+// Operations
+storage()->rename('path/to/old.txt', 'path/to/new.txt');
+storage()->copy('path/to/file.txt', 'path/to/copy.txt');
+storage()->move('path/to/file.txt', 'path/to/new-location.txt');
+storage()->delete('path/to/file.txt');
+storage()->chmod('path/to/file.txt', 0777);
+storage()->link('path/to/file.txt', 'path/to/symlink.txt'); // symlink
+
+// Error handling (all operations return bool)
+if (!$result) { $errors = storage()->errors(); }
+```
+
+### Folders
+
+```php
+storage()->createFolder('path/to/folder');
+storage()->createFolder('path/to/folder', ['recursive' => true, 'rename' => true]);
+storage()->isEmpty('path/to/folder');
+storage()->list('path/to/folder');
+storage()->list('path/to/folder', '*.php');
+storage()->list('path/to/folder', function ($file) {
+ return storage()->isFile($file) && storage()->extension($file) === 'php';
+});
+storage()->rename('path/to/folder', 'path/to/new-folder');
+storage()->copy('path/to/folder', 'path/to/new-folder');
+storage()->move('path/to/folder', 'path/to/new-location');
+storage()->delete('path/to/folder');
+```
+
+### File Uploads (via request)
+
+```php
+$uploaded = request()->upload('fileInput', './uploads');
+
+if ($uploaded) {
+ echo $uploaded['name']; // file name
+ echo $uploaded['path']; // file path
+ echo $uploaded['size']; // file size
+ echo $uploaded['type']; // file type
+ echo $uploaded['extension']; // file extension
+ echo $uploaded['url']; // full URL (if APP_URL is set)
+} else {
+ $errors = request()->errors();
+}
+
+// With options
+$uploaded = request()->upload('fileInput', './uploads', [
+ 'maxSize' => 1024 * 1024, // 1MB
+ 'allowedTypes' => ['image'], // image, video, audio, text, presentation, compressed, spreadsheet, application
+ 'allowedExtensions' => ['jpg', 'png'],
+ 'validate' => true,
+ 'overwrite' => false,
+ 'rename' => true,
+ 'recursive' => false,
+ 'mode' => 0777,
+]);
+
+// Multiple files ()
+$uploaded = request()->upload('files', './uploads');
+// $uploaded is an array of file info arrays
+```
+
+### Cloud / S3 Storage
+
+```bash
+leaf install s3
+```
+
+```env
+AWS_ACCESS_KEY_ID=...
+AWS_SECRET_ACCESS_KEY=...
+AWS_DEFAULT_REGION=weur
+AWS_BUCKET=bucket-name
+AWS_URL=https://cdn.example.com
+AWS_ENDPOINT=https://something.r2.cloudflarestorage.com
+```
+
+```php
+// Use withBucket() in place of a local path
+$url = request()->upload('file', 'local/path', withBucket('path/in/bucket'))['url'] ?? null;
+
+storage()->createFile(
+ withBucket("imports/$id/video.mp4"),
+ $videoContent,
+ ['rename' => true, 'recursive' => true]
+);
+```
+
+---
+
+## Queues & Background Jobs (MVC Only)
+
+```bash
+leaf install queue
+```
+
+Default backend: database (`leaf_php_jobs` table). Worker starts automatically with `leaf serve`.
+
+### Creating a Job
+
+```bash
+leaf g:job SendEmail # β app/jobs/SendEmailJob.php
+```
+
+```php
+namespace App\Jobs;
+use Leaf\Job;
+
+class SendEmailJob extends Job
+{
+ protected $delay = 10; // seconds before processing
+ protected $tries = 3; // max attempts (default 3)
+ protected $delayBeforeRetry = 5; // seconds before retry
+ protected $timeout = 60; // seconds before kill
+ protected $connection = 'myOtherConnection'; // specific DB connection
+
+ public function handle($userId)
+ {
+ UserMailer::welcome($userId)->send();
+ }
+}
+```
+
+### Dispatching
+
+```php
+dispatch(SendEmailJob::class); // no data
+dispatch(SendEmailJob::with($userId)); // with data
+dispatch([ // multiple jobs
+ SendEmailJob::with($userId),
+ SendReminderJob::with($userId),
+]);
+```
+
+> Jobs are stateless β no access to request, session, or auth. Pass all needed data via `with()`.
+
+### Scheduled Jobs
+
+```php
+public function schedule()
+{
+ return $this->every('week')->on('tuesday')->at('8:00');
+ // or:
+ return $this->cron('0 8 * * 2');
+}
+```
+
+Leaf auto-detects `schedule()` and runs it. Worker must be running.
+
+### Worker
+
+```bash
+leaf queue:work # start manually
+php leaf queue:work & # background (production β use Supervisor instead, see app-config.md)
+```
+
+### Redis Queue
+
+```env
+QUEUE_CONNECTION=redis
+```
+
+```bash
+leaf config:publish queue # β config/queue.php
+```
+
+---
+
+## Billing (MVC Only)
+
+```bash
+leaf install stripe # or:
+leaf install paystack
+```
+
+```env
+BILLING_PROVIDER=stripe
+STRIPE_API_KEY=sk_test_XXXX
+STRIPE_PUBLISHABLE_KEY=pk_test_XXXX
+STRIPE_WEBHOOK_SECRET=whsec_XXXX
+```
+
+### One-Time Payment
+
+```php
+$session = billing()->charge([
+ 'currency' => 'USD',
+ 'description' => 'Cart purchase',
+ 'metadata' => ['cart_id' => $cartId],
+]);
+
+response()->redirect($session->url());
+```
+
+### Payment Callback
+
+```php
+$event = billing()->callback();
+
+if ($event->isSuccessful()) {
+ // success
+} else {
+ $session = $event->session();
+ $metadata = $event->metadata();
+}
+```
+
+### Subscriptions
+
+```bash
+leaf scaffold:subscriptions # generates controllers, routes, views, config
+leaf config:billing # publishes plans to Stripe
+```
+
+```php
+// config/billing.php β tiers
+'tiers' => [
+ [
+ 'name' => 'Starter',
+ 'description' => 'For individuals',
+ 'trialDays' => 5,
+ 'price.monthly' => 100,
+ 'price.yearly' => 1000,
+ 'discount' => 25,
+ 'features' => [/* ... */],
+ ],
+]
+```
+
+Display pricing component (Blade):
+```blade
+@component('components.billing.pricing')
+```
+
+Check billing status:
+```blade
+@if (auth()->user()->hasActiveSubscription())
+@if (auth()->user()->subscription() === 'Starter')
+@if (auth()->user()->isOnTrial())
+```
+
+### Webhooks
+
+```php
+$event = billing()->webhook();
+
+$event->type();
+$event->is('invoice.payment_succeeded');
+$event->tier();
+$event->subscription();
+$event->user();
+$event->previousSubscriptionTier();
+$event->activateSubscription();
+$event->cancelSubscription();
+$event->data();
+$event->metadata();
+```
+
+### Billing Middleware
+
+```php
+app()->get('/pro', ['middleware' => 'billing.subscribed', function () { /* ... */ }]);
+app()->get('/upgrade', ['middleware' => 'billing.not-subscribed:Starter', 'SubscriptionController@subscribe']);
+
+// billing.subscribed, billing.subscribed:plan, billing.not-subscribed, billing.not-subscribed:plan
+// Customize failure behavior:
+billing()->middleware('billing.subscribed', fn () => response()->redirect('/upgrade'));
+```
+
+### Raw Provider Access
+
+```php
+$stripeSession = billing()->provider()->checkout->sessions->create([/* ... */]);
+```
+
+---
+
+## Internationalization / i18n β Lingo
+
+```bash
+leaf install lingo
+```
+
+### Translation Files
+
+```yaml
+# app/locales/fr.yml
+hero.title: "Bonjour le monde"
+greeting.message: "Bonjour, {{ name }}!"
+farewell.message: "Au revoir, $name!"
+```
+
+### Usage
+
+```php
+lingo('hero.title'); // "Bonjour le monde"
+lingo('greeting.message', ['name' => 'John']); // "Bonjour, John!"
+```
+
+Blade: `@lingo('hero.title')`
+
+### Modes
+
+| Mode | Strategy | Config |
+|---|---|---|
+| Route (default) | Adds `/en/`, `/fr/` prefixes to all routes | β |
+| Header | Uses `Accept-Language` header | `LOCALES_STRATEGY=header` |
+| Session | Stores locale in session | `LOCALES_STRATEGY=session` |
+
+### Locale Switching
+
+```php
+app()->post('/language/switch', function () {
+ lingo()->setCurrentLocale(request()->get('locale'));
+});
+```
+
+### Locale Info
+
+```php
+lingo()->getCurrentLocale(); // 'fr_FR'
+lingo()->getCurrentLanguage(); // 'fr'
+lingo()->getDefaultLocale();
+lingo()->is('fr');
+lingo()->getAvailableLocales(); // ['en', 'fr', 'de']
+lingo()->getAvailableLocalesWithNames(); // ['en' => 'English', 'fr' => 'FranΓ§ais']
+lingo()->url('home'); // '/fr/home'
+lingo()->variants(['en' => '/products', 'fr' => '/produits']);
+```
+
+### Multi-language Routes (Route Mode)
+
+```php
+app()->get('/products', [
+ 'lingo.routes' => [
+ 'en' => '/products',
+ 'fr' => '/produits',
+ 'de' => '/produkte',
+ ],
+ 'ProductsController@index'
+]);
+
+// Disable localization for a route
+app()->get('/api/health', ['lingo.routes' => false, function () { /* ... */ }]);
+```
+
+---
+
+## Sitemap Generator
+
+```bash
+leaf install sitemap
+```
+
+Sitemap auto-generates from your Leaf routes on first request. Regenerate manually:
+
+```php
+sitemap()->generate();
+```
+
+### Route Config
+
+```php
+app()->get('/about', [
+ 'sitemap' => ['changefreq' => 'monthly', 'priority' => 0.5],
+ function () { echo 'About'; }
+]);
+
+// Dynamic route with model
+app()->get('/blog/{slug}', [
+ 'sitemap' => [
+ 'changefreq' => 'weekly',
+ 'priority' => 0.8,
+ 'model' => \App\Models\Post::class, // or 'posts' (table name)
+ 'parameter' => 'slug',
+ 'exclude' => ['status' => 'draft'],
+ ],
+ 'BlogController@show'
+]);
+```
+
+### Custom Datasources
+
+```php
+sitemap()->source(function () {
+ $posts = db()->select('posts')->get();
+
+ foreach ($posts as $post) {
+ // Map route pattern to actual URL
+ sitemap()->map('/blog/{slug}', "/blog/{$post->slug}", [
+ 'lastmod' => $post->updated_at,
+ 'changefreq' => 'weekly',
+ 'priority' => 0.8,
+ ]);
+
+ // Multi-language URLs
+ sitemap()->map('/blog/{slug}', [
+ "/en/blog/{$post->slug_en}" => ['changefreq' => 'weekly', 'priority' => 0.8],
+ "/fr/blog/{$post->slug_fr}" => ['changefreq' => 'weekly', 'priority' => 0.8],
+ ]);
+
+ // Add URL directly (without mapping a route)
+ sitemap()->add("/blog/{$post->slug}", [
+ 'lastmod' => $post->updated_at,
+ 'changefreq' => 'weekly',
+ 'priority' => 0.8,
+ ]);
+ }
+});
+```
diff --git a/src/public/ai/references/app-config.md b/src/public/ai/references/app-config.md
new file mode 100644
index 00000000..e75bc472
--- /dev/null
+++ b/src/public/ai/references/app-config.md
@@ -0,0 +1,175 @@
+# Leaf 5 β App Config & Deployment Reference
+
+## Environment Variables
+
+MVC: auto-loaded. Basic apps: use `vlucas/phpdotenv` or `symfony/dotenv` to load `.env`.
+
+```php
+$value = _env('SECRET_KEY', 'default-if-not-found');
+```
+
+---
+
+## Application Mode
+
+```php
+app()->config(['mode' => 'production']);
+```
+
+Or set `APP_ENV` in `.env` β Leaf detects it automatically.
+
+Run code only in a specific mode:
+```php
+app()->script('production', function () {
+ // only runs in production
+});
+```
+
+In Blade:
+```blade
+@env('production')
+ {{-- only renders in production --}}
+@endenv
+```
+
+### Production Checklist
+- Set all environment variables for production
+- Turn off debug mode so errors aren't rendered to your users
+
+---
+
+## Logging
+
+MVC: pre-configured. Logs saved to `storage/logs/` by default.
+
+Basic apps:
+```bash
+leaf install logger
+```
+
+```php
+app()->config([
+ 'log.enabled' => true,
+ 'log.dir' => __DIR__ . '/logs/',
+ 'log.file' => 'app.log', // default: log.txt
+]);
+```
+
+To disable logging entirely:
+```bash
+leaf uninstall logger
+```
+
+---
+
+## `rescue()` β Safe Execution
+
+```php
+$value = rescue(function () {
+ // code that may throw
+}, 'default value');
+
+rescue(function () {
+ // code that may throw β no return value needed
+});
+```
+
+---
+
+## Maintenance Mode
+
+```php
+// Basic app
+app()->config(['app.down' => true]);
+
+// MVC β set in .env
+// APP_DOWN=true
+```
+
+Custom down page:
+```php
+app()->setDown(function () {
+ echo 'Custom maintenance page!';
+});
+```
+
+---
+
+## Service Container
+
+Register once, fetch anywhere on the Leaf instance. This is service location by design (not constructor injection); re-registering a name replaces it, which is how tests swap in fakes.
+
+```php
+app()->register('something', function ($c) {
+ return new Something();
+});
+
+$something = app()->something;
+$something->doSomething();
+
+// or
+app()->something->doSomething();
+```
+
+---
+
+## URL Rewriting
+
+Map all requests to `index.php` so Leaf's router handles them.
+
+**Nginx:**
+```nginx
+try_files $uri /index.php?$query_string;
+```
+
+**Apache (`.htaccess`):**
+```apache
+RewriteEngine on
+RewriteCond %{REQUEST_FILENAME} !-d
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteRule . index.php [L]
+```
+
+---
+
+## Deployment
+
+### Vite / Inertia Apps
+
+Build assets before deploying:
+```bash
+npm run build # or yarn build / pnpm build
+```
+
+> Skipping this causes a broken app or CORS errors with Inertia. Add to your deploy script.
+
+### Queues / Workers
+
+Quick start (small apps):
+```bash
+php leaf queue:work &
+```
+
+Production (recommended) β use Supervisor:
+```bash
+sudo apt update && sudo apt install supervisor -y
+sudo nano /etc/supervisor/conf.d/leaf-queue.conf
+```
+
+```ini
+[program:leaf-queue]
+process_name=%(program_name)s_%(process_num)02d
+command=php leaf queue:work
+autostart=true
+autorestart=true
+numprocs=1
+redirect_stderr=true
+stdout_logfile=/var/log/leaf-queue.log
+```
+
+```bash
+sudo supervisorctl reread
+sudo supervisorctl update
+sudo supervisorctl start leaf-queue
+sudo supervisorctl status leaf-queue
+```
diff --git a/src/public/ai/references/auth.md b/src/public/ai/references/auth.md
new file mode 100644
index 00000000..3507f990
--- /dev/null
+++ b/src/public/ai/references/auth.md
@@ -0,0 +1,436 @@
+# Leaf 5 β Auth Reference
+
+## Installation
+
+```bash
+leaf install auth
+```
+
+## Configuration
+
+### MVC β via `.env`
+
+```env
+AUTH_DB_TABLE=users
+AUTH_DB_ID=id
+AUTH_TIMESTAMPS=true
+AUTH_TIMESTAMPS_FORMAT='YYYY-MM-DD HH:mm:ss'
+AUTH_SESSION=true
+AUTH_TOKEN_SECRET=your-secret
+```
+
+For full config:
+```bash
+leaf config:publish auth # β config/auth.php
+```
+
+### Basic App β connect to DB
+
+```php
+// Direct connection
+auth()->connect([
+ 'dbtype' => 'mysql',
+ 'host' => '...',
+ 'dbname' => '...',
+ 'username' => '...', // note: username, not user β same keys as db()->connect()
+ 'password' => '...',
+ 'charset' => '...',
+ 'port' => '...',
+]);
+
+// Existing PDO instance
+$db = new PDO('mysql:dbname=test;host=127.0.0.1', 'root', '');
+auth()->dbConnection($db);
+
+// Existing Leaf DB connection β nothing needed, picked up automatically
+```
+
+Note: in MVC apps, auth, `db()`, and Eloquent models all share **one** database connection β Leaf DB lazily borrows Eloquent's PDO (leafs/db 5.1+), so an auth read can never lock out a model write. On SQLite the framework also defaults to WAL journal mode with a busy timeout (`config/database.php`) to keep concurrent PHP processes safe; leave those in place. To point auth at a *different* database entirely, pass a PDO explicitly with `auth()->dbConnection($pdo)`.
+
+### Lite apps
+
+Auth works in lite apps with one line of wiring: `db()->connect([...])` β auth picks up the connection automatically (or share explicitly with `auth()->dbConnection(db()->connection())`). Know these lite realities: `.env` files are NOT loaded (there is no dotenv loader β export env vars in the real environment, or use `token.secret` config), `leaf scaffold:auth` and `leaf key:generate` are MVC-only commands, and session-only auth needs no signing secret at all (5.1.2+). Set `redirect.login`/`redirect.guest` to real routes β the defaults point at MVC scaffold paths.
+
+### Runtime Config
+
+```php
+auth()->config('id.key', 'admin_id'); // custom primary key (default: 'id')
+auth()->config('db.table', 'admins'); // custom table (default: 'users')
+auth()->config('hidden', ['field.id', 'email_verified_at']); // hide fields from user output
+auth()->config('unique', ['email', 'username']); // unique fields on register
+auth()->config('password.key', 'pass'); // custom password field name
+auth()->config('password.key', false); // disable password auth entirely
+auth()->config('redirect.login', '/signin'); // where auth.required sends guests (default: /auth/login)
+auth()->config('redirect.guest', '/'); // where auth.guest sends signed-in users (default: /dashboard)
+```
+
+---
+
+## Scaffold (MVC only)
+
+```bash
+leaf scaffold:auth
+```
+
+Generates a full auth system:
+- User model + database schema
+- Login, register, dashboard controllers
+- Auth routes
+- Route protection middleware
+- Views (tailored to your frontend setup)
+- Dashboard + account update example
+
+Just update the UI to match the design.
+
+---
+
+## Login
+
+```php
+$success = auth()->login([
+ 'email' => 'user@example.com',
+ 'password' => 'password',
+]);
+
+if ($success) {
+ $data = auth()->data();
+ // ['user' => [...], 'accessToken' => '...', 'refreshToken' => '...']
+} else {
+ $error = auth()->errors();
+}
+```
+
+### Session Auth
+
+Leaf uses JWT by default. Switch to sessions:
+
+```php
+auth()->config('session', true);
+// or AUTH_SESSION=true in .env
+```
+
+Session-only apps need no token secret (5.1.2+): JWTs are minted lazily, so the signing secret is only required the moment something reads `tokens()` or `getAuthInfo()`. Two more session facts worth knowing: reading the authenticated user rotates the session id on each request (old ids stay valid, so this is safe but visible in Set-Cookie), and `register()`/`login()` validate nothing beyond credentials and uniqueness β run `request()->validate()` on the input first, or a one-character password creates an account.
+
+With sessions, `login()` starts the session automatically:
+
+```php
+$success = auth()->login(['email' => '...', 'password' => '...']);
+
+if ($success) {
+ return response()->redirect('/dashboard');
+} else {
+ $error = auth()->errors();
+}
+```
+
+Fine-tune session cookies:
+```php
+auth()->config('session.cookie', [
+ 'secure' => true,
+ 'httponly' => true,
+ 'samesite' => 'lax', // 'strict', 'lax', 'none', or null
+]);
+```
+
+Token lifetime:
+```php
+auth()->config('token.lifetime', 60 * 60 * 24 * 7); // 1 week
+auth()->config('token.lifetime', 0); // never expire
+auth()->config('session.lifetime', 60 * 60 * 24); // session: 1 day
+auth()->config('session.lifetime', 0); // session: never expire
+```
+
+---
+
+## Current User
+
+```php
+auth()->user(); // current authenticated user object
+auth()->id(); // current user's ID
+auth()->data(); // full auth data: user + tokens
+auth()->oauthToken(); // OAuth token (if signed in via OAuth)
+```
+
+---
+
+## Logout
+
+```php
+auth()->logout();
+// For session auth, also redirect after:
+auth()->logout();
+response()->redirect('/login');
+```
+
+---
+
+## Registration
+
+```php
+$success = auth()->register([
+ 'username' => 'mychi',
+ 'email' => 'mychi@leafphp.dev',
+ 'password' => 'password',
+]);
+
+if (!$success) {
+ $errors = auth()->errors();
+}
+```
+
+---
+
+## What the users table needs
+
+Auth works with any table (default `users`) that has:
+
+- An id column (`id` by default, configurable with `id.key`) β auto-increment or UUID both work
+- An `email` column (lowercased automatically on register) and whatever you use as `password.key` (default `password`)
+- Any other columns you pass to `register()`/`createUserFor()` β auth stores what you give it
+
+You do not need a roles column: roles are stored in a `leaf_auth_user_roles` column that auth **creates automatically on first `assign()`**, including on existing tables. Do not add it to your schema by hand.
+
+## The user object
+
+`auth()->user()` returns a `Leaf\Auth\User`. Know its shape before building API responses:
+
+- `$user->get()` returns the user data **minus hidden fields**. The password column and `remember_token` are ALWAYS stripped (5.1.2+), whatever `hidden` says. The `hidden` config controls the rest β its default is `['field.id', 'field.password', 'remember_token']`, where `field.id`/`field.password` are sentinels resolving to your configured `id.key`/`password.key`; prefer them over literal column names when overriding. Everything not hidden (`email_verified_at`, timestamps, any custom column) IS included, and in Inertia apps ships to the browser in the shared `auth` prop β add columns to `hidden` before they reach the client
+- `$user->id()` returns the id even though it is hidden from `get()`
+- `$user->roles()` returns the assigned roles β combine with `get()` when your API response needs both: `[...$user->get(), 'roles' => $user->roles()]`
+- After `assign()`, the database is updated but the in-memory user is not β read `roles()` (which reflects the assignment) rather than re-reading `get()`
+- With bearer tokens, `auth()->user()` reconstructs the user from the JWT on every request (roles included from the database), so role checks like `auth()->user()->is('admin')` work statelessly across requests
+
+## Finding a User
+
+```php
+$user = auth()->find(1); // find by ID (user does NOT need to be logged in)
+
+$user->assign('admin');
+$user->transactions()->create([/* ... */]);
+```
+
+---
+
+## OAuth
+
+```php
+// Register provider (in routes/index.php or index.php)
+// Install a League OAuth2 provider package, then configure via .env:
+// GOOGLE_AUTH_CLIENT_ID=...
+// GOOGLE_AUTH_CLIENT_SECRET=...
+
+$token = auth()->client('google')->anyLeagueMethod();
+$token = auth()->client('github')->getAccessToken('authorization_code', ['code' => $_GET['code']]);
+$user = auth()->client('github')->getResourceOwner($token);
+
+$success = auth()->fromOAuth([
+ 'token' => $token,
+ 'user' => [
+ 'name' => $user->getName(),
+ 'email' => $user->getEmail(),
+ 'avatar' => $user->getAvatar() ?? null,
+ ],
+]);
+
+if (!$success) {
+ $error = auth()->errors();
+}
+
+// After success, user is authenticated normally
+$currentUser = auth()->user();
+$oauthToken = auth()->oauthToken(); // retrieve saved OAuth token later
+```
+
+---
+
+## Password
+
+```php
+// Custom password field
+auth()->config('password.key', 'pass');
+
+// Disable password auth (e.g. multi-step auth)
+auth()->config('password.key', false);
+
+// Custom hashing
+auth()->config('password.encode', function ($password) {
+ return Password::hash($password);
+});
+
+// Custom verification
+auth()->config('password.verify', function ($password, $hash) {
+ return Password::verify($password, $hash);
+});
+auth()->config('password.verify', false); // disable verification
+```
+
+---
+
+## Error Messages
+
+```php
+auth()->config('messages.loginParamsError', 'Username is incorrect!');
+auth()->config('messages.loginPasswordError', 'Password is incorrect!');
+```
+
+---
+
+## Checking Auth State
+
+```php
+$user = auth()->user();
+
+if ($user) {
+ // logged in
+} else {
+ $errors = auth()->errors();
+}
+
+// In routes
+app()->get('/protected', function () {
+ if (!auth()->user()) {
+ response()->redirect('/login'); // web
+ // or:
+ response()->json(['error' => 'Unauthorized', 'data' => auth()->errors()], 401); // API
+ }
+ // ...
+});
+
+// By ID
+$id = auth()->id(); // null if not logged in
+```
+
+---
+
+## Built-in Auth Middleware
+
+Use directly as route/group middleware β no setup needed:
+
+```php
+// Require login
+app()->get('/protected', ['middleware' => 'auth.required', function () { /* ... */ }]);
+app()->group('/dashboard', ['middleware' => 'auth.required', function () { /* ... */ }]);
+
+// Guest only (redirect logged-in users)
+app()->get('/login', ['middleware' => 'auth.guest', function () { /* ... */ }]);
+
+// Verified users only
+app()->group('/dashboard', ['middleware' => 'auth.verified', function () { /* ... */ }]);
+
+// Unverified users only
+app()->group('/verify', ['middleware' => 'auth.unverified', function () { /* ... */ }]);
+```
+
+### Customize middleware failure behavior
+
+```php
+auth()->middleware('auth.required', fn () => response()->exit('You need to be logged in', 401));
+
+auth()->middleware('auth.guest', fn () => response()->exit('You are already logged in', 403));
+```
+
+---
+
+## Roles & Permissions (RBAC)
+
+Leaf Auth stores roles directly on the user (not in a separate DB table) for performance β fewer queries, no joins. Trade-off: querying all users with a role is slightly less performant.
+
+### Define Roles
+
+Call once near the top of your app (e.g. `app/routes/index.php` in MVC):
+
+```php
+auth()->createRoles([
+ 'admin' => ['view user', 'view users', 'create user', 'delete user'],
+ 'user' => ['view user', 'view users'],
+ 'guest' => ['view user'],
+]);
+```
+
+### Assign Roles
+
+```php
+// On register
+$success = auth()->register([/* ... */]);
+if ($success) { auth()->user()->assign('admin'); }
+
+// On login
+$success = auth()->login([/* ... */]);
+if ($success) { auth()->user()->assign('user'); }
+
+// Create user without login flow
+$user = auth()->createUserFor([
+ 'name' => 'John', 'email' => 'john@example.com', 'password' => 'password'
+]);
+if ($user) { $user->assign('admin'); }
+
+// By ID (user doesn't need to be logged in)
+$user = auth()->find(1);
+if ($user) { $user->assign('admin'); }
+
+// Unassign
+auth()->user()->unassign('admin');
+```
+
+### Check Roles
+
+```php
+auth()->user()->is('admin');
+auth()->user()->is(['admin', 'user']); // true if any match
+auth()->user()->isNot('admin');
+auth()->user()->isNot(['admin', 'user']); // true if none match
+```
+
+### Check Permissions
+
+```php
+auth()->user()->can('view user');
+auth()->user()->can(['view user', 'create user']); // true if any match
+auth()->user()->cannot('view user');
+auth()->user()->cannot(['view user', 'create user']); // true if none match
+```
+
+### Role/Permission Middleware
+
+Pipe-separate multiple values with `|`:
+
+```php
+app()->get('/admin', ['middleware' => 'is:admin|user|organizer', function () { /* ... */ }]);
+app()->get('/users', ['middleware' => 'can:view user|create user', function () { /* ... */ }]);
+app()->get('/guest', ['middleware' => 'isNot:admin', function () { /* ... */ }]);
+app()->get('/locked', ['middleware' => 'cannot:view user|create user', function () { /* ... */ }]);
+```
+
+Default behavior on failure: 404. Customize:
+
+```php
+auth()->middleware('is', fn () => response()->redirect('/login'));
+
+auth()->middleware('can', fn () => response()->json(['error' => 'Forbidden'], 403));
+```
+
+### Inspect Roles & Permissions
+
+```php
+auth()->roles(); // all defined roles
+auth()->user()->roles(); // roles assigned to current user
+auth()->user()->permissions(); // permissions of current user's roles
+```
+
+| Config Key | Default | Description |
+|---|---|---|
+| `db.table` | `'users'` | Database table |
+| `id.key` | `'id'` | Primary key field |
+| `timestamps` | `true` | Auto created_at/updated_at |
+| `timestamps.format` | `'YYYY-MM-DD HH:mm:ss'` | Timestamp format |
+| `password.key` | `'password'` | Password field name |
+| `password.encode` | `Password::hash` | Hashing function |
+| `password.verify` | `Password::verify` | Verification function |
+| `unique` | `['email', 'username']` | Fields checked for uniqueness. Fields missing from the data are skipped, so a table without `username` is safe β but set `['email']` explicitly to save the wasted check |
+| `hidden` | `['field.id', 'field.password', 'remember_token']` | Hidden from user output |
+| `session` | `false` | Use sessions instead of JWT |
+| `session.lifetime` | `86400` | Session TTL in seconds |
+| `session.cookie` | `[secure, httponly, samesite]` | Cookie params |
+| `token.lifetime` | `31536000` (1 year) | JWT TTL in seconds |
+| `token.secret` | derived | Resolution order: `token.secret` config, then `AUTH_TOKEN_SECRET` env, then derived from `APP_KEY`. With none of the three, token operations throw a clear error β set one before going live |
+| `token.secret` | env `AUTH_TOKEN_SECRET` | JWT signing secret |
diff --git a/src/public/ai/references/cors.md b/src/public/ai/references/cors.md
new file mode 100644
index 00000000..966a514a
--- /dev/null
+++ b/src/public/ai/references/cors.md
@@ -0,0 +1,62 @@
+# Leaf 5 β CORS Reference
+
+## Installation
+
+```bash
+leaf install cors
+```
+
+**MVC:** works automatically after install.
+**Basic/Lite:** initialize manually before your routes:
+
+```php
+app()->cors();
+// ... routes follow
+```
+
+## Configuration
+
+### MVC β via `.env`
+
+```env
+CORS_ALLOWED_ORIGINS='/\.example\.com$/'
+CORS_ALLOWED_METHODS='GET,HEAD,PUT,PATCH,POST,DELETE'
+CORS_ALLOWED_HEADERS='*'
+```
+
+For dynamic config (e.g. a function for `origin`), publish the config file:
+
+```bash
+leaf config:publish cors
+# β config/cors.php
+```
+
+### Basic/Lite β inline
+
+```php
+app()->cors([
+ 'origin' => ['http://example.com', 'http://example.org'],
+ 'methods' => ['GET', 'POST'],
+]);
+```
+
+## All Options
+
+| Option | Type | Description |
+|---|---|---|
+| `origin` | String, RegExp, Array | `Access-Control-Allow-Origin` |
+| `methods` | String or Array | `Access-Control-Allow-Methods` |
+| `allowedHeaders` | String or Array | `Access-Control-Allow-Headers`. Defaults to request's `Access-Control-Request-Headers`. |
+| `exposedHeaders` | String or Array | `Access-Control-Expose-Headers` |
+| `credentials` | Boolean | `Access-Control-Allow-Credentials` |
+| `maxAge` | Integer | `Access-Control-Max-Age` |
+| `preflightContinue` | Boolean | Pass preflight to next handler |
+| `optionsSuccessStatus` | Integer | Status for `OPTIONS` requests. Default `204`; use `200` for IE11/SmartTV. |
+
+## `origin` Examples
+
+```php
+'origin' => 'http://example.com' // exact match
+'origin' => '/^https:\/\/(.*\.)?example\.com$/' // regex, anchored so evil-example.com never matches
+'origin' => ['http://example1.com', '/\.example2\.com$/'] // array: string + regex
+```
diff --git a/src/public/ai/references/database.md b/src/public/ai/references/database.md
new file mode 100644
index 00000000..94082760
--- /dev/null
+++ b/src/public/ai/references/database.md
@@ -0,0 +1,241 @@
+# Leaf 5 β Database Reference
+
+## Leaf DB (Query Builder)
+
+Pre-installed in MVC. For Basic apps:
+```bash
+leaf install db
+```
+
+> In MVC, prefer Models over `db()` for structured code. `db()` is great for quick queries and Basic apps.
+
+### Connecting
+
+```php
+// MySQL (default)
+db()->connect([
+ 'host' => '127.0.0.1',
+ 'username' => 'root',
+ 'password' => '',
+ 'dbname' => 'myapp',
+]);
+
+// PostgreSQL
+db()->connect([
+ 'dbtype' => 'pgsql',
+ 'host' => '127.0.0.1',
+ 'username' => 'root',
+ 'password' => '',
+ 'dbname' => 'myapp',
+ 'port' => '5432',
+]);
+
+// SQLite
+db()->connect([
+ 'dbtype' => 'sqlite',
+ 'dbname' => 'db.sqlite',
+]);
+```
+
+### Multiple Connections
+
+```php
+db()->addConnections([
+ 'conn1' => ['dbtype' => 'mysql', 'host' => '...', /* ... */],
+ 'conn2' => ['dbtype' => 'pgsql', 'host' => '...', /* ... */],
+], 'conn1'); // 'conn1' is default
+
+db('conn2')->select('users')->all(); // use specific connection
+```
+
+---
+
+## Raw SQL
+
+```php
+$users = db()->query('SELECT * FROM users')->all();
+
+db()->query('SELECT * FROM users WHERE id = ?')
+ ->bind('1')
+ ->fetchObj();
+
+db()->query('CREATE DATABASE dbname')->execute(); // no return value
+$users = db()->query('SELECT * FROM users')->first();
+```
+
+Result methods: `->all()`, `->first()`, `->fetchObj()`, `->fetchAssoc()`, `->fetchAll()`
+
+`query()` takes no bindings parameter β chain `->bind(...$values)` for each `?` placeholder. There is no `whereIn()`; build the placeholders yourself: `db()->query('SELECT * FROM users WHERE id IN (' . rtrim(str_repeat('?,', count($ids)), ',') . ')')->bind(...$ids)`.
+
+---
+
+## Query Builder
+
+### DDL
+
+```php
+db()->create('dbname')->execute();
+db()->drop('dbname')->execute();
+```
+
+### Insert
+
+```php
+db()->insert('users')->params(['username' => 'mychi'])->execute();
+
+// Unique constraint check
+db()->insert('users')
+ ->params(['username' => 'mychi', 'email' => 'mychi@leafphp.dev', 'password' => hash('test')])
+ ->unique('username', 'email')
+ ->execute();
+
+$lastId = db()->lastInsertId();
+```
+
+### Select
+
+```php
+db()->select('users')->all();
+db()->select('users', 'name, created_at')->all(); // specific columns
+
+db()->select('users')->where('name', 'John Doe')->fetchObj();
+db()->select('users')->where(['name' => 'John Doe', 'age' => 20])->fetchAssoc();
+
+// Operators: >, <, >=, <=, !=, LIKE
+db()->select('items')->where('tag', 'LIKE', '%new%')->fetchAll();
+
+// orWhere
+db()->select('users')->where('name', 'John Doe')->orWhere('age', 20)->fetchAll();
+db()->select('users')->orWhere(['name' => 'John Doe', 'age' => 20])->fetchAll();
+
+db()->select('users')->find(1); // find by id
+```
+
+### Relationships
+
+```php
+db()->select('users')->with('posts')->all();
+db()->select('users')->where('id', 1)->with('posts')->first();
+```
+
+### Update
+
+```php
+db()->update('users')->params(['name' => 'Jane Doe'])->execute(); // all rows
+db()->update('users')->params(['name' => 'Jane Doe'])->where('id', 1)->execute();
+```
+
+### Delete
+
+```php
+db()->delete('users')->execute(); // all rows β careful!
+db()->delete('users')->where('id', '1')->execute();
+```
+
+---
+
+## Result Modifiers
+
+```php
+// Hide fields from results
+db()->select('users')->hidden('password')->all();
+db()->select('users')->where('id', '1')->hidden('remember_token', 'reset_q_id')->first();
+
+// Add computed fields
+db()->select('users')->add('full_name', 'Something Here')->all();
+db()->select('users')->where('id', '1')->add('full_name', 'Value')->first();
+
+// Ordering & limiting
+db()->select('users')->orderBy('created_at', 'DESC')->all();
+db()->select('users')->orderBy('created_at')->limit(10)->all();
+
+// Count
+db()->select('users')->count();
+db()->select('users')->where('age', '>', 20)->count();
+```
+
+> As of leafs/db 5.1.1, `count()` counts SELECT result rows correctly on every driver. Older versions used PDO's `rowCount()`, which returns 0 for SELECTs on sqlite.
+
+---
+
+## Transactions
+
+```php
+db()->transaction(function ($db) {
+ $db->insert('purchases')->params([/* ... */])->execute();
+ $db->update('balances')->params([/* ... */])->where([/* ... */])->execute();
+ // can include non-DB logic too
+});
+
+$success = db()->transaction(function () { /* ... */ });
+if (!$success) {
+ $errors = db()->errors();
+}
+```
+
+> In Leaf MVC (leafs/db 5.1+), `db()` and your models share one connection, so a `db()->beginTransaction()` covers model reads and writes too β wrap a check-then-insert (an availability check plus a booking insert, say) in one transaction to close the race between concurrent requests. On older setups where the connections are separate, transactions only cover `db()` queries.
+
+---
+
+## Error Handling
+
+```php
+$res = db()->insert('users')->params('username', 'mychi')->unique('username')->execute();
+
+if ($res === false) {
+ response()->exit(db()->errors());
+ // errors: ['username' => 'username already exists']
+}
+```
+
+---
+
+## Leaf Redis
+
+MVC only (Basic app support planned).
+
+```bash
+leaf install redis
+```
+
+### Config (`.env`)
+
+```env
+REDIS_HOST=127.0.0.1
+REDIS_PORT=6379
+REDIS_PASSWORD=null
+REDIS_SESSION=false
+```
+
+For advanced config:
+```bash
+leaf config:publish redis # β config/redis.php
+```
+
+### Usage
+
+```php
+echo redis()->ping();
+
+// Set
+redis()->set('name', 'Michael');
+redis()->set(['name' => 'Michael', 'age' => 22]);
+redis()->set('name', 'Michael', 3600); // with TTL (seconds)
+redis()->set('name', 'Michael', 0); // no expiration
+
+// Get
+$name = redis()->get('name');
+$values = redis()->get(['name', 'age']); // ['name' => 'Michael', 'age' => 22]
+
+// Delete
+redis()->delete('name');
+redis()->delete(['name', 'age']);
+
+// Check
+$exists = redis()->exists('name');
+$exists = redis()->has('name'); // alias
+
+// Utility
+redis()->flush();
+$keys = redis()->keys();
+```
diff --git a/src/public/ai/references/dates.md b/src/public/ai/references/dates.md
new file mode 100644
index 00000000..f1184442
--- /dev/null
+++ b/src/public/ai/references/dates.md
@@ -0,0 +1,71 @@
+# Dates & Time β `tick()` (leafs/date)
+
+Leaf's date library follows **dayjs semantics** β dayjs format tokens, dayjs method names, immutable-style chaining. It is NOT Carbon and NOT php `date()`.
+
+```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
+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
+```
+
+Calendar flow: parse in the user's zone β `->utc()` to store β `->tz(viewerZone)` to render.
+
+## Formatting
+
+```php
+tick()->format('YYYY-MM-DD HH:mm:ss'); // dayjs tokens, NOT php date() tokens
+tick()->format('MMM D, YYYY'); // Jan 15, 2026
+tick($date)->fromNow(); // "2 hours ago"
+tick($date)->from($otherDate); // relative between two dates
+tick()->toDateString(); // 2026-01-15
+tick()->toDateTimeString(); // 2026-01-15 12:00:00
+tick()->toIsoString(); // ISO 8601
+tick()->toTimestamp(); // unix timestamp (int)
+tick()->toDateTime(); // native \DateTime when you need one
+```
+
+Common token trap: `YYYY-MM-DD`, `HH:mm:ss`, `ddd`/`dddd` for weekday names. `Y-m-d` and friends are php tokens and will render wrong.
+
+## Manipulation
+
+```php
+tick()->add(3, 'days');
+tick()->subtract(1, 'month');
+tick()->startOf('day'); // also: week, month, year, hour...
+tick()->endOf('month');
+tick()->set('hour', 9);
+tick()->get('month');
+tick()->year(2027); // getter with no arg, setter with one
+```
+
+## Comparison
+
+```php
+tick($a)->isBefore($b);
+tick($a)->isAfter($b);
+tick($a)->isSame($b);
+tick($a)->isSameDay($b); // also isSameMonth, isSameYear
+tick($a)->isBetween($b, $c); // and isBetweenOrEqual
+tick()->isLeapYear();
+```
+
+## Numeric differences (5.1+)
+
+`diff()` returns a signed integer, dayjs style: positive when this date is after the argument, truncated toward zero. Days and months are calendar-aware, so night counts survive DST boundaries.
+
+```php
+tick($checkOut)->diff($checkIn, 'days'); // nights in a booking β 4, not 3.958
+tick($expiry)->diff('now', 'hours');
+tick($b)->diff($a, 'months'); // units: years|months|days|hours|minutes|seconds
+```
+
+It accepts strings, `DateTime`, or another `tick()` instance.
+
+## With Eloquent models (MVC)
+
+Model `date`/`timestamp` casts return **Carbon** instances (Eloquent's own library), not `tick()` objects β `$booking->check_in->endOf('day')` on a model date is Carbon API, not this one. Use `tick()` in the layers you control (services, queries, formatting): `tick()->format('YYYY-MM-DD')` for query bounds, `tick($model->check_in->toDateTimeString())` to bring a model date into tick when you need its API.
+
+Stored `date` columns hold `YYYY-MM-DD HH:MM:SS` β compare with `whereDate()`, not `where()`, or exact boundary dates misbehave (see mvc.md).
diff --git a/src/public/ai/references/lite.md b/src/public/ai/references/lite.md
new file mode 100644
index 00000000..d34d9e3b
--- /dev/null
+++ b/src/public/ai/references/lite.md
@@ -0,0 +1,71 @@
+# Lite Apps β The Setup Contract
+
+Leaf modules are configured automatically in MVC apps: mvc-core knows where views, storage, and the database live, so it wires every module at boot. A lite app has no imposed structure, so there is nowhere for Leaf to safely assume paths β **you write that wiring yourself, once, at the top of `index.php`**. This is by design, not a gap. This file is the complete contract: what MVC would have configured for you, in copy-paste form.
+
+A lite app that uses the database, views, and Vite starts like this:
+
+```php
+connect([
+ 'dbtype' => 'sqlite',
+ 'dbname' => __DIR__ . '/db.sqlite',
+]);
+
+// Lite views live in `views/`, not the MVC default of `app/views/`.
+// Without `views.cache`, compiled blade files land in the project root.
+app()->config('views.path', 'views');
+app()->config('views.cache', __DIR__ . '/storage/cache');
+
+// ... routes ...
+
+app()->run();
+```
+
+Newly scaffolded apps (cli 5.0.6+) get the views config written into `index.php` by `leaf view:install`. Everything below is per-module detail.
+
+## Database
+
+`db()->connect([...])` once, before any query. Every module that needs the database (auth, session-on-db, etc.) picks up this connection automatically β connect is the only wiring auth needs in a lite app.
+
+## Auth
+
+Session-only auth needs no signing secret (auth 5.1.2+) β tokens are only minted if you read them, and reading them without a secret tells you exactly what to set. Remember that lite apps do NOT load `.env` files (no dotenv loader ships), so `AUTH_TOKEN_SECRET=...` in a file does nothing: export it in the real environment or set `token.secret` in config. Point the middleware redirects at routes you actually have: `auth()->config(['redirect.login' => '/login', 'redirect.guest' => '/'])` β the defaults are MVC scaffold paths. And validate registration input yourself with `request()->validate()` before calling `auth()->register()`; auth only checks credentials and uniqueness.
+
+## Views (Blade / BareUI / Inertia)
+
+`views.path` tells every engine where templates live; `views.cache` is where compiled blade goes (inertia 5.0.1+ defaults it to `storage/cache` and creates it, but setting it explicitly keeps the path intentional). Inertia's root shell is `views/_inertia.blade.php`, written by `leaf view:install`.
+
+## Vite
+
+leafs/vite 5.0.1+ defaults match a root-served lite app: the `hot` file and `build/` directory in the project root, assets served from `/build`. If your layout differs (or you are on 5.0), configure it explicitly:
+
+```php
+\Leaf\Vite::config([
+ 'hotFile' => __DIR__ . '/hot', // written by the dev server when running
+ 'build' => 'build', // filesystem path, relative to cwd β never absolute
+ 'assets' => '/build', // the URL prefix browsers request
+]);
+```
+
+## Schema files (recommended over raw DDL)
+
+`leaf install schema` works in lite apps β it brings its own database layer. Wire it with an Illuminate connection and you get the same YAML schema files MVC uses, instead of hand-writing `CREATE TABLE`:
+
+```php
+$capsule = new \Illuminate\Database\Capsule\Manager();
+$capsule->addConnection(['driver' => 'sqlite', 'database' => __DIR__ . '/db.sqlite']);
+$capsule->setAsGlobal();
+$capsule->bootEloquent();
+
+\Leaf\Schema::setDbConnection($capsule);
+\Leaf\Schema::migrate(__DIR__ . '/database/users.yml'); // one call per schema file
+```
+
+Run that from a small `migrate.php` script (or a guarded route in dev). Schema file format, column types, and seeds are documented in `mvc.md` β the format is identical in lite apps.
+
+## What stays MVC-only
+
+`g:*`, `scaffold:*`, and `db:*` console commands need the MVC console and do not exist in lite apps (`leaf up` migrates you to MVC when you want them). Everything else β auth, session, cookies, mail, cors, cache, date, fs β works in lite apps with no wiring beyond the database connection above.
diff --git a/src/public/ai/references/mail-fetch-cache.md b/src/public/ai/references/mail-fetch-cache.md
new file mode 100644
index 00000000..f67bca99
--- /dev/null
+++ b/src/public/ai/references/mail-fetch-cache.md
@@ -0,0 +1,243 @@
+# Leaf 5 β Mail, Fetch & HTTP Cache Reference
+
+## Mail
+
+```bash
+leaf install mail
+```
+
+### Connecting β Basic App
+
+```php
+mailer()->connect([
+ 'host' => 'smtp.mailtrap.io',
+ 'port' => 2525,
+ 'security' => 'STARTTLS',
+ 'auth' => ['username' => '...', 'password' => '...'],
+ 'debug' => 'SERVER', // 'SERVER', false, or PHPMailer SMTPDebug value
+ 'keepAlive' => true,
+ 'defaults' => [
+ 'senderName' => 'My App',
+ 'senderEmail' => 'no-reply@myapp.com',
+ 'replyToName' => 'Support',
+ 'replyToEmail' => 'support@myapp.com',
+ ],
+]);
+```
+
+### Connecting β MVC (via `.env`)
+
+```env
+MAIL_HOST=sandbox.smtp.mailtrap.io
+MAIL_PORT=2525
+MAIL_USERNAME=xxx
+MAIL_PASSWORD=xxx
+MAIL_DEBUG=SERVER
+MAIL_SENDER_NAME='Leaf MVC'
+MAIL_SENDER_EMAIL=user@example.com
+```
+
+For advanced config (OAuth, etc.):
+```bash
+leaf config:publish mail # β config/mail.php
+```
+
+### Creating & Sending Mail
+
+```php
+$mail = mailer()->create([
+ 'subject' => 'Hello!',
+ 'body' => 'This is the email body.',
+ 'recipientEmail' => 'user@example.com',
+ 'recipientName' => 'John Doe',
+ 'senderName' => 'My App', // optional if set in defaults
+ 'senderEmail' => 'app@myapp.com', // optional if set in defaults
+ 'replyToName' => 'Support',
+ 'replyToEmail' => 'support@myapp.com',
+ 'cc' => 'cc@example.com',
+ 'bcc' => 'bcc@example.com',
+ 'isHtml' => true,
+ 'altBody' => 'Plain text fallback for clients without HTML support.',
+]);
+
+$mail->send();
+
+if (!$mail->send()) {
+ $errors = $mail->errors(); // errors tied to this specific mail object
+}
+```
+
+### Attachments
+
+```php
+mailer()
+ ->create([/* ... */])
+ ->attach('./invoice.pdf')
+ ->attach(['./file1.txt', './file2.txt'])
+ ->send();
+```
+
+### Blade Templates (MVC)
+
+```php
+mailer()->create([
+ 'subject' => 'Welcome!',
+ 'body' => view('emails.welcome', ['user' => $user]),
+]);
+```
+
+### MVC Mailers
+
+Generate a mailer class:
+```bash
+leaf g:mailer welcome # β app/mailers/WelcomeMailer.php
+```
+
+```php
+namespace App\Mailers;
+
+class WelcomeMailer
+{
+ public static function firstLogin($user)
+ {
+ return mailer()->create([
+ 'subject' => 'Welcome to Leaf MVC!',
+ 'body' => 'We are excited to have you on board.',
+ 'recipientEmail' => $user->email,
+ 'recipientName' => $user->name,
+ ]);
+ }
+}
+```
+
+Use from a controller, job, or anywhere:
+```php
+use App\Mailers\WelcomeMailer;
+
+WelcomeMailer::firstLogin($user)->send();
+```
+
+---
+
+## Fetch (HTTP Client)
+
+```bash
+leaf install fetch
+```
+
+Inspired by JavaScript's Fetch API and Axios.
+
+### GET Requests
+
+```php
+$res = fetch('https://jsonplaceholder.typicode.com/todos/1');
+$res = fetch()->get('https://jsonplaceholder.typicode.com/todos/1');
+
+response()->json($res->data); // data, status, headers, request
+```
+
+### Other Methods
+
+```php
+$res = fetch()->post('https://api.example.com/posts', [
+ 'title' => 'foo',
+ 'body' => 'bar',
+]);
+fetch()->put($url, $data);
+fetch()->patch($url, $data);
+fetch()->delete($url);
+fetch()->head($url);
+fetch()->options($url);
+
+response()->json($res->data);
+```
+
+### Full Options
+
+```php
+$res = fetch([
+ 'method' => 'PUT',
+ 'url' => 'https://api.example.com/resource/1',
+ 'data' => ['firstName' => 'Fred'],
+]);
+```
+
+### Base URL
+
+```php
+fetch()->baseUrl('https://jsonplaceholder.typicode.com');
+
+$res = fetch('/todos'); // β typicode.com/todos
+$res = fetch()->post('/posts', [...]);
+```
+
+### All Request Options
+
+| Option | Default | Description |
+|---|---|---|
+| `url` | β | Request URL |
+| `method` | `'GET'` | HTTP method |
+| `baseUrl` | `''` | Prepended to `url` unless `url` is absolute |
+| `headers` | `[]` | Custom headers |
+| `params` | `[]` | URL query parameters (appended for any method) |
+| `data` | `[]` | Request body, JSON-encoded by default; form-encoded with a `application/x-www-form-urlencoded` Content-Type header; becomes query params on GET |
+| `timeout` | `0` | Timeout in seconds (0 = no timeout) |
+| `auth` | `[]` | HTTP Basic auth: `['username' => ..., 'password' => ...]` |
+| `maxRedirects` | `5` | Max redirects (0 = none) |
+| `rawResponse` | `false` | Skip JSON parsing |
+| `verifyHost` | `true` | SSL host verification |
+| `verifyPeer` | `true` | SSL peer verification |
+| `curl` | `[]` | Additional curl options, applied last so they win |
+
+Non-2xx statuses return normally (check `$res->status`); only network-level failures (unreachable host, timeout) throw `\Exception`. Response header names are lower cased. Non-JSON bodies are returned as the raw string. `Fetch::config([...])` updates defaults app-wide.
+
+---
+
+## HTTP Cache
+
+```php
+use Leaf\Http\Cache;
+```
+
+> Use either `etag()` OR `lastModified()` per route β never both together. Call them before other route code.
+
+### ETag
+
+```php
+app()->get('/resource', function () {
+ Cache::etag('unique-resource-tag'); // change tag when resource changes
+ echo 'Cached after first request';
+});
+```
+
+### Last Modified
+
+```php
+app()->get('/resource', function () {
+ Cache::lastModified(1617383991); // UNIX timestamp of last modification
+ echo 'Cached after first request';
+});
+```
+
+### Expires
+
+Use with `etag()` or `lastModified()` to set client-side cache expiry:
+
+```php
+app()->get('/resource', function () {
+ Cache::etag('unique-tag');
+ Cache::expires('+1 week'); // string (strtotime) or UNIX timestamp
+ echo 'Cached for one week';
+});
+```
+
+### Custom Cache Headers
+
+```php
+use Leaf\Http\Headers;
+
+app()->get('/resource', function () {
+ Headers::set('Cache-Control', 'public, max-age=3600');
+ echo 'Cached for 1 hour';
+});
+```
diff --git a/src/public/ai/references/middleware.md b/src/public/ai/references/middleware.md
new file mode 100644
index 00000000..2b4938b1
--- /dev/null
+++ b/src/public/ai/references/middleware.md
@@ -0,0 +1,92 @@
+# Leaf 5 β Middleware Reference
+
+## Global Middleware (all routes)
+
+```php
+// Closure β Basic/Lite style
+app()->use(function () {
+ echo request()->getMethod() . ' ' . request()->getPath();
+});
+
+// Class β MVC style
+use App\Middleware\LogRequestMiddleware;
+app()->use(LogRequestMiddleware::class);
+app()->use(AnotherMiddleware::class); // stack multiple
+```
+
+## Route-Level Middleware
+
+```php
+// Named (registered globally)
+app()->registerMiddleware('auth', function () {
+ if (!auth()->user()) response()->redirect('/login');
+});
+
+app()->get('/home', ['middleware' => 'auth', function () { /* ... */ }]);
+
+// Class
+app()->get('/my-route', ['middleware' => LogRequestMiddleware::class, 'MyController@index']);
+
+// Inline closure
+$mw = function () { echo request()->getMethod(); };
+app()->get('/home', ['middleware' => $mw, function () { echo 'Home'; }]);
+app()->group('/admin', ['middleware' => $mw, function () {
+ app()->get('/', function () { echo 'dashboard'; });
+}]);
+```
+
+> Use closures in Basic/Lite. Use classes in MVC.
+
+## MVC Middleware Class
+
+Generate with: `leaf g:middleware LogRequest`
+
+```php
+namespace App\Middleware;
+use Leaf\Middleware;
+
+class LogRequestMiddleware extends Middleware {
+ public function call() {
+ $method = request()->getMethod();
+ $uri = request()->getPath();
+ echo "[$method] $uri\n";
+ }
+}
+```
+
+## Passing Data from Middleware β Handler
+
+Use `response()->next()` to pass data, `request()->next()` to read it.
+Data is consumed on first read and removed from the request lifecycle.
+
+### Closure version
+
+```php
+app()->registerMiddleware('logRequest', function ($next) {
+ echo request()->getMethod();
+ return response()->next('You can pass any value here');
+});
+
+app()->get('/home', ['middleware' => 'logRequest', function () {
+ $data = request()->next(); // "You can pass any value here"
+ echo $data;
+}]);
+```
+
+### MVC class version
+
+```php
+class LogRequestMiddleware extends Middleware {
+ public function call($next) {
+ echo request()->getMethod();
+ return response()->next('You can pass any value');
+ }
+}
+
+class MyController extends Controller {
+ public function index() {
+ $data = request()->next(); // "You can pass any value"
+ echo $data;
+ }
+}
+```
diff --git a/src/public/ai/references/mvc.md b/src/public/ai/references/mvc.md
new file mode 100644
index 00000000..e1b9785e
--- /dev/null
+++ b/src/public/ai/references/mvc.md
@@ -0,0 +1,405 @@
+# Leaf 5 β MVC Reference
+
+## Controllers
+
+```bash
+leaf g:controller Users # β app/controllers/UsersController.php
+leaf g:controller photos --resource # CRUD controller
+leaf g:controller photos --api # API CRUD controller (no create/edit)
+
+leaf g:controller Users -m # + model
+leaf g:controller Users -t # + view template
+leaf g:controller Users -a # + model + schema file
+```
+
+### Basic Controller
+
+```php
+namespace App\Controllers;
+
+class UsersController extends Controller
+{
+ public function index()
+ {
+ return response()->json(['message' => 'UsersController@index']);
+ }
+
+ public function show($id)
+ {
+ return response()->render('users/show', ['id' => $id]);
+ }
+}
+```
+
+### Routes
+
+```php
+app()->get('/users', 'UsersController@index');
+app()->get('/users/{id}', 'UsersController@show');
+app()->get('/users', ['middleware' => 'auth', 'UsersController@index']);
+
+// Resource routes (CRUD) β all 7 routes in one line
+app()->resource('/photos', 'PhotosController');
+
+// API resource (no create/edit routes)
+app()->apiResource('/photos', 'PhotosController');
+```
+
+**Resource route map:**
+
+| Method | URI | Action |
+|---|---|---|
+| GET | `/photos` | `index` |
+| GET | `/photos/create` | `create` |
+| POST | `/photos` | `store` |
+| GET | `/photos/{id}` | `show` |
+| GET | `/photos/{id}/edit` | `edit` |
+| PUT/PATCH | `/photos/{id}` | `update` |
+| DELETE | `/photos/{id}` | `destroy` |
+
+---
+
+## Models (MVC Only)
+
+Built on Eloquent ORM β full Eloquent docs apply.
+
+```bash
+leaf g:model Flight # β app/models/Flight.php
+```
+
+### Basic Model
+
+```php
+namespace App\Models;
+
+class Flight extends Model
+{
+ // Conventions: table = 'flights' (snake_case plural of class name)
+ // Primary key: 'id' (auto-incrementing integer)
+ // Timestamps: created_at, updated_at (auto-managed)
+}
+```
+
+### Overriding Conventions
+
+```php
+class Flight extends Model
+{
+ protected $table = 'my_flights'; // custom table name
+ protected $primaryKey = 'flight_id'; // custom PK
+ public $incrementing = false; // non-auto-incrementing PK
+ protected $keyType = 'string'; // non-integer PK type
+ public $timestamps = false; // disable timestamps
+ protected $dateFormat = 'U'; // custom timestamp format
+ protected $connection = 'secondary'; // specific DB connection
+
+ const CREATED_AT = 'creation_date'; // custom timestamp column names
+ const UPDATED_AT = 'last_update';
+
+ protected $attributes = [ // default attribute values
+ 'delayed' => false,
+ ];
+}
+```
+
+### Querying
+
+```php
+// All records
+$flights = Flight::all();
+
+// With constraints
+$flights = Flight::where('active', 1)->orderBy('name', 'desc')->take(10)->get();
+
+// Single record
+$flight = Flight::find(1);
+$flight = Flight::where('number', 'FR 900')->first();
+
+// Refresh from DB
+$freshFlight = $flight->fresh(); // new instance, original unchanged
+$flight->refresh(); // re-hydrate existing instance
+```
+
+### Insert
+
+```php
+$flight = new Flight;
+$flight->name = request()->get('name');
+$flight->save(); // created_at/updated_at set automatically
+```
+
+### Update
+
+```php
+$flight = Flight::find(1);
+$flight->name = 'New Name';
+$flight->save(); // updated_at set automatically
+```
+
+### Delete
+
+```php
+// Hard delete (permanent)
+$flight = Flight::find(1);
+$flight->delete();
+
+// Soft delete β add to schema: softDeletes: true
+// Add trait to model:
+use Illuminate\Database\Eloquent\SoftDeletes;
+class Flight extends Model {
+ use SoftDeletes;
+}
+// Now delete() sets deleted_at instead of removing the row
+$flight->delete();
+```
+
+---
+
+## Schema Files (MVC Only)
+
+One YAML file per table β defines columns, seeds, and relationships. No separate migration files needed.
+
+```bash
+leaf g:schema posts # β app/database/posts.yml
+
+leaf db:migrate # migrate all tables
+leaf db:migrate users # migrate specific table
+leaf db:rollback # revert last migration
+leaf db:rollback users # revert specific table
+leaf db:rollback --steps=3
+leaf db:reset # rollback all β re-migrate
+leaf db:reset users
+leaf db:drop # drop all tables (careful!)
+leaf db:drop users
+leaf db:seed # run all seeders
+leaf db:seed users # seed specific table
+```
+
+### Full Schema Example
+
+```yaml
+# app/database/posts.yml
+
+# auto-adds: id (auto-increment), created_at, updated_at
+increments: false # disable auto-increment id
+timestamps: false # disable auto timestamps
+connection: postsDbConnection # specific DB connection
+
+columns:
+ # Simple type
+ name: string
+
+ # With properties
+ email:
+ type: string
+ length: 255
+ unique: true
+ bio:
+ type: text
+ nullable: true
+ status:
+ type: enum
+ values: [draft, published, archived]
+ default: draft
+ verified_at:
+ type: timestamp
+ nullable: true
+ useCurrent: false
+ score:
+ type: integer
+ unsigned: true
+ default: 0
+ index: true
+
+# Foreign keys β generates user_id β users.id
+relationships:
+ - User
+ - Category
+
+seeds:
+ count: 10
+ truncate: true
+ model: CustomModel # optional: specify model for __seeder method
+ data:
+ - name: 'John Doe'
+ email: 'john@example.com'
+ - name: 'Jane Doe'
+ email: 'jane@example.com'
+```
+
+Seeding is opt-in: a schema file with no `seeds:` block seeds nothing. With a `seeds:` block, rows come from `data:` (inline), or from `model:`'s `__seeder()` method repeated `count` times.
+
+### Faker Seeds
+
+```yaml
+seeds:
+ count: 10
+ data:
+ name: '@faker.name'
+ email: '@faker.email'
+ password: '$2y$10$hashedpassword'
+```
+
+### Column Types
+
+`boolean`, `integer`, `bigInteger`, `char`, `string`, `text`, `tinyText`, `mediumText`, `longText`, `date`, `enum`, `increments`, `bigIncrements`, `smallIncrements`, `decimal`, `float`, `double`, `unsignedBigInteger`, `id`, `uuid`, `json`, `jsonb`, `timestamp`
+
+Eloquent writes `date` and `timestamp` columns as `YYYY-MM-DD HH:MM:SS`. Compare them with `whereDate()`, not `where()` β a string comparison against a bare `YYYY-MM-DD` treats an exact boundary date as greater-than and returns wrong rows with no error.
+
+### Column Properties
+
+| Property | Description |
+|---|---|
+| `type` | Column type (required) |
+| `length` | Column length (default 255 for string) |
+| `nullable` | Allow null (default false) |
+| `default` | Default value |
+| `unsigned` | Unsigned integer |
+| `index` | Add index |
+| `unique` | Unique constraint |
+| `primary` | Primary key |
+| `values` | Enum/set values |
+| `autoIncrement` | Auto-increment |
+| `useCurrent` | Default to current timestamp |
+| `useCurrentOnUpdate` | Update to current timestamp on update |
+| `onDelete` | FK ON DELETE action |
+| `onUpdate` | FK ON UPDATE action |
+| `comment` | Column comment (not SQLite) |
+
+### Custom Seeder in Model
+
+```php
+class User extends Model
+{
+ public static function __seeder()
+ {
+ return [
+ 'name' => fake()->name(),
+ 'email' => fake()->email(),
+ 'password' => '$2y$10$hashedpassword',
+ ];
+ }
+}
+```
+
+Schema auto-detects `__seeder` when model name matches table name (e.g. `User` β `users`, `TestUser` β `test_users`).
+
+---
+
+## Services (MVC)
+
+Plain PHP classes for reusable business logic. Store in `app/services/` by convention.
+
+```php
+// app/services/StatsService.php
+namespace App\Services;
+
+class StatsService
+{
+ public function getDashboardData()
+ {
+ return ['users' => 1500, 'sales' => 2300, 'revenue' => 12000];
+ }
+}
+```
+
+Use with `make()` β always prefer over `new`:
+
+```php
+use App\Services\StatsService;
+
+class DashboardController extends Controller
+{
+ public function index()
+ {
+ return response()->inertia(
+ 'dashboard',
+ make(StatsService::class)->getDashboardData()
+ );
+ }
+}
+```
+
+> Use services when: logic is reused across controllers, you want thin controllers, or you're encapsulating complex business operations.
+
+---
+
+## Custom Libraries (MVC)
+
+For logic that doesn't fit controllers, models, or helpers. Stored in `lib/` (not autoloaded by default).
+
+```bash
+leaf config:lib # creates lib/ and enables autoloading
+```
+
+```php
+// lib/Math.php
+namespace MyApp\Lib;
+
+class Math {
+ public static function add($a, $b) { return $a + $b; }
+}
+
+// lib/helpers.php (function-based)
+namespace Lib;
+function add($a, $b) { return $a + $b; }
+```
+
+```php
+// In a controller
+use MyApp\Lib\Math;
+$sum = Math::add(1, 2);
+
+// Function-based
+use function Lib\add;
+$sum = add(1, 2);
+```
+
+Non-autoloadable libraries: move to `lib/MyLibrary/` and create `lib/mylibrary.php` that requires the index:
+
+```php
+// lib/mylibrary.php
+require __DIR__ . '/MyLibrary/index.php';
+```
+
+---
+
+## MVC Global Helpers
+
+### Path Helpers
+
+```php
+AppPaths(); // all paths as array
+AppPaths('controllers'); // specific path
+
+assets('css/main.css'); // public/assets/css/main.css
+ConfigPath('db.php'); // config/db.php
+CommandsPath('MainCommand.php'); // app/console/MainCommand.php
+ControllersPath('UsersController.php'); // app/controllers/UsersController.php
+DatabasePath('migrations'); // app/database/migrations
+FactoriesPath('UserFactory.php'); // app/database/factories/UserFactory.php
+HelpersPath('MainHelper.php'); // app/helpers/MainHelper.php
+LibPath('MainLib.php'); // lib/MainLib.php
+MigrationsPath('MainMigration.php'); // app/database/migrations/MainMigration.php
+ModelsPath('User.php'); // app/models/User.php
+PublicPath('index.php'); // public/index.php
+RoutesPath('_auth.php'); // app/routes/_auth.php
+SeedsPath('MainSeed.php'); // app/database/seeds/MainSeed.php
+StoragePath('logs'); // storage/logs
+ViewsPath('home.blade.php'); // app/views/home.blade.php
+```
+
+### Config Helpers
+
+```php
+MvcConfig(); // all config files
+MvcConfig('db'); // config/db.php as array
+MvcConfig('db', 'host'); // specific key from config
+
+AppConfig(); AppConfig('debug');
+AuthConfig(); AuthConfig('session');
+CorsConfig(); CorsConfig('origin');
+DatabaseConfig(); DatabaseConfig('host');
+MailConfig(); MailConfig('host');
+ViewConfig(); ViewConfig('viewEngine');
+```
diff --git a/src/public/ai/references/request.md b/src/public/ai/references/request.md
new file mode 100644
index 00000000..7d9f3972
--- /dev/null
+++ b/src/public/ai/references/request.md
@@ -0,0 +1,163 @@
+# Leaf 5 β Request Reference
+
+All input is auto-sanitized by default. Pass `false` to disable.
+
+## Getting Data
+
+`get()` works for all request types β query params, form, files, JSON:
+
+```php
+$item = request()->get('item'); // single item (sanitized)
+$data = request()->get(['name', 'email']); // multiple β assoc array
+$data = request()->get('data', false); // sanitization off
+```
+
+Sanitization HTML-escapes the value (`it's` becomes `it's`). That is right for values echoed into raw HTML, and wrong for values you **store**: persisting escaped text corrupts it for any frontend that escapes at render (React, Vue, blade `{{ }}` all double-escape it). For data headed to the database, pass `false` and rely on parameterized queries plus render-time escaping.
+
+```php
+$body = request()->body(); // full request body
+$body = request()->body(false); // full body, no sanitization
+$name = request()->params('name', 'John Doe'); // with default value
+```
+
+### Type-specific getters
+
+```php
+$name = request()->query('name', 'John Doe'); // GET / URL query only
+$name = request()->postData('name', 'John Doe'); // POST/PUT/PATCH only
+$file = request()->files('file'); // $_FILES
+```
+
+### `try()` β only return keys present in the request
+
+```php
+$data = request()->try(['name', 'email']); // only existing keys
+$data = request()->try(['name', 'email'], false); // sanitization off
+$data = request()->try(['name', 'email'], false, true); // also skip empty strings
+```
+
+## File Uploads
+
+Requires Leaf FS (`leaf install fs@v4` if not on MVC).
+
+```php
+$info = request()->upload('profile_pic', './uploads');
+
+// Custom filename
+$info = request()->upload('profile_pic', './uploads', [
+ 'name' => 'new_name',
+ 'rename' => true,
+]);
+```
+
+### Return shape
+
+On success, `upload()` returns an array with these keys:
+
+```php
+[
+ 'name' => 'photo.png', // final stored filename
+ 'size' => 52436, // bytes
+ 'type' => 'image', // derived from the extension
+ 'path' => 'uploads/photo.png', // normalized path on disk
+ 'extension' => 'png',
+ 'url' => 'https://myapp.test/uploads/photo.png',
+]
+```
+
+If the file input holds multiple files, you get an array of these arrays instead, one per file.
+
+The `url` is built from the `APP_URL` env value. When `APP_URL` may be unset, building your own URL from `name` (or `path`) is more reliable than trusting `url`.
+
+On failure, `upload()` returns `false`. A missing file or a rejected extension puts the message in `request()->errors()`; failures inside the filesystem layer (size limit, existing file, move errors) land in `\Leaf\FS\File::errors()` instead, so check both when debugging.
+
+### Config options
+
+```php
+request()->upload('doc', './uploads', [
+ 'name' => 'report', // store under this name
+ 'rename' => true, // timestamp-prefix the name if a file already exists
+ 'overwrite' => false, // replace an existing file with the same name
+ 'maxSize' => 5_000_000, // max bytes, 0 = unlimited
+ 'extensions' => ['pdf'], // allowed extensions, checked before storing
+ 'validate' => true, // enable allowedTypes/allowedExtensions checks
+ 'allowedTypes' => ['image'],
+ 'allowedExtensions' => ['png', 'jpg'],
+]);
+```
+
+When neither `rename` nor `overwrite` is set and the destination file exists, the upload fails rather than clobbering the file.
+
+## Headers
+
+```php
+$all = request()->headers(); // all headers (sanitized)
+$contentType = request()->headers('Content-Type'); // specific header
+$all = request()->headers(safeData: false); // no sanitization
+$contentType = request()->headers('Content-Type', false);
+```
+
+## Validation
+
+`validate()` returns only the validated fields on success, `false` on failure.
+
+```php
+app()->post('/register', function () {
+ $data = request()->validate([
+ 'name' => 'text',
+ 'email' => 'email',
+ 'password' => 'min:8',
+ ]);
+
+ if (!$data) {
+ $errors = request()->errors(); // validation error messages
+ response()->json(['errors' => $errors], 422);
+ return;
+ }
+
+ // $data = only the validated fields
+});
+```
+
+## Client Info
+
+```php
+request()->getIp(); // client IP (may be proxy IP)
+request()->getUserLocation(); // geo data from client IP
+request()->getLocationFromIp('1.2.3.4'); // geo data for specific IP
+```
+
+`getUserLocation()` returns: `country`, `countryCode`, `region`, `regionName`, `city`, `zip`, `lat`, `lon`, `timezone`, `currency`, `ip`, `continent`, `continentCode`.
+
+> `getIp()` may return the proxy/load-balancer IP. Validate before using for rate limiting or logging.
+
+## Request Metadata
+
+```php
+request()->getHost(); // "leafphp.dev"
+request()->getHostWithPort(); // "leafphp.dev:80"
+request()->getPort(); // 80
+request()->getScheme(); // "http" or "https"
+request()->getPath(); // root URI + resource URI
+request()->getUrl(); // scheme + host (+ port if non-standard)
+request()->getReferrer(); // referrer URL
+request()->getUserAgent(); // user agent string
+request()->getContentType(); // "application/json;charset=utf-8"
+request()->getMediaType(); // "application/json"
+request()->getMediaTypeParams(); // ['charset' => 'utf-8']
+request()->getContentCharset(); // "utf-8"
+request()->getContentLength(); // content length in bytes
+```
+
+## Request Type Checks
+
+```php
+request()->typeIs('GET'); // case-insensitive: 'GET', 'post', 'Delete' all work
+request()->isAjax(); // XHR check
+request()->isXhr(); // alias for isAjax()
+request()->isFormData(); // multipart/form-data
+
+if (request()->typeIs('GET')) {
+ return response()->exit('GET not allowed', 405);
+}
+```
diff --git a/src/public/ai/references/response.md b/src/public/ai/references/response.md
new file mode 100644
index 00000000..6d6025e8
--- /dev/null
+++ b/src/public/ai/references/response.md
@@ -0,0 +1,100 @@
+# Leaf 5 β Response Reference
+
+## Output Methods
+
+```php
+response()->json(['message' => 'Hello']); // JSON (+ auto Content-Type)
+response()->json($data, 201); // with status code
+response()->plain('Hello, world!'); // plain text
+response()->markup('
Hello
'); // HTML string
+response()->page('path/to/file.html'); // static HTML file (not templated)
+response()->xml(''); // XML
+response()->noContent(); // 204 No Content
+response()->download('path/to/file.pdf');
+response()->download('path/to/file.pdf', 'new-name.pdf', 200);
+```
+
+## Templating & Inertia
+
+```php
+response()->view('home', ['name' => 'Michael']); // configured template engine
+response()->render('home', ['name' => 'Michael']); // alias for view()
+response()->inertia('home', ['name' => 'Michael']); // Inertia (React/Vue/Svelte)
+```
+
+## Redirects
+
+```php
+response()->redirect('/example'); // path within app
+response()->redirect(['example']); // by route name
+response()->redirect('https://example.com'); // external URL
+```
+
+## Halt Execution
+
+```php
+response()->exit('An error occurred', 500); // stops execution β code below won't run
+response()->die('An error occurred', 500); // alias for exit()
+```
+
+## Custom Responses
+
+```php
+response()
+ ->withHeader([
+ 'Content-Type' => 'application/pdf',
+ 'Content-Length' => $dataLength,
+ 'Content-Disposition' => "inline; filename=\"$filename\""
+ ])
+ ->echo($rawData);
+```
+
+## Headers
+
+Leaf sets standard content headers automatically. Use `withHeader()` for custom ones.
+Chains with any response method:
+
+```php
+response()->withHeader('X-Custom', 'value');
+response()->withHeader([
+ 'Content-Type' => 'application/json',
+ 'X-Custom-Header' => 'value',
+]);
+
+response()
+ ->withHeader('X-Custom', 'value')
+ ->json(['message' => 'Hello']);
+```
+
+## Cookies & Flash
+
+```php
+response()->withCookie('name', 'Michael', time() + 86400)->json('...');
+response()->withoutCookie('name')->json('...');
+response()->withoutCookie(['name', 'other'])->json('...');
+
+response()->withFlash('message', 'Done!')->json('...');
+response()->withFlash('message', 'Done!')->redirect('/home');
+```
+
+## Passing Data to Next Handler
+
+```php
+response()->next('some value'); // pass data to route handler or next middleware
+// read with: request()->next() // consumed on first read, then removed
+```
+
+## Utility Header Methods (`Leaf\Http\Headers`)
+
+```php
+Leaf\Http\Headers::contentPlain(200); // text/plain
+Leaf\Http\Headers::contentHtml(200); // text/html
+Leaf\Http\Headers::contentXml(200); // application/xml
+Leaf\Http\Headers::contentJSON(200); // application/json
+
+Leaf\Http\Headers::accessControl('Allow-Origin', 'https://example.com', 200);
+Leaf\Http\Headers::accessControl([
+ 'Allow-Origin' => '*',
+ 'Allow-Headers' => '*',
+]);
+```
diff --git a/src/public/ai/references/routing.md b/src/public/ai/references/routing.md
new file mode 100644
index 00000000..da6b52a8
--- /dev/null
+++ b/src/public/ai/references/routing.md
@@ -0,0 +1,131 @@
+# Leaf 5 β Routing Reference
+
+## Basic Methods
+
+```php
+app()->get('/home', function () { /* ... */ });
+app()->post('/home', function () { /* ... */ });
+app()->put('/home', function () { /* ... */ });
+app()->patch('/home', function () { /* ... */ });
+app()->delete('/home', function () { /* ... */ });
+app()->options('/home', function () { /* ... */ });
+
+app()->match('GET|POST', '/users', function () { /* ... */ });
+
+app()->run(); // dispatch β always call at the end
+```
+
+## Controllers (MVC)
+
+```php
+app()->get('/home', 'HomeController@index');
+app()->get('/home', ['name' => 'home', 'HomeController@index']);
+```
+
+## Views & Inertia
+
+```php
+app()->view('/home', 'home'); // configured template engine
+app()->inertia('/home', 'Home'); // Inertia (React/Vue/Svelte)
+```
+
+## 404 Handler
+
+```php
+app()->set404(fn () => response()->json(['error' => 'Page not found']));
+```
+
+## Route Options
+
+Pass an array before the handler to set metadata (name, middleware, sitemap options, etc.):
+
+```php
+app()->get('/home', ['name' => 'home', function () {
+ $route = app()->getRoute();
+ // $route: pattern, path, name, method, handler, + any custom options
+ echo $route['name'];
+}]);
+```
+
+## Named Routes & Redirects
+
+```php
+app()->get('/home', ['name' => 'home', function () { /* ... */ }]);
+
+response()->redirect(['home']); // by route name
+response()->redirect('/login'); // by path
+response()->redirect('https://x.com'); // external
+
+$route = app()->route('home'); // get route info by name
+```
+
+## Route Groups
+
+```php
+// Basic prefix group
+app()->group('/admin', function () {
+ app()->get('/', function () { echo 'dashboard'; });
+ app()->get('/users', function () { echo 'users'; });
+});
+
+// Group with options (namespace, middleware, etc.)
+app()->group('/admin', ['namespace' => 'MyNameSpace', function () {
+ app()->get('/form', 'FormsController@index'); // β MyNameSpace\FormsController@index
+}]);
+
+// Group with middleware
+app()->group('/admin', ['middleware' => 'auth', function () {
+ app()->get('/', function () { echo 'dashboard'; });
+}]);
+```
+
+## Dynamic Routes
+
+```php
+// Named parameter
+app()->get('/movies/{movieId}', function ($id) {
+ echo 'Movie #' . $id;
+});
+
+// Multiple parameters
+app()->get('/movies/{foo}/photos/{bar}', function ($movieId, $photoId) {
+ echo 'Movie #' . $movieId . ', photo #' . $photoId;
+});
+
+// Constraint: placeholder must match the regex after the colon
+app()->get('/movies/{id:[0-9]+}/photos/{photoId:[0-9]+}', function ($movieId, $photoId) {
+ echo 'Movie #' . $movieId . ', photo #' . $photoId;
+});
+```
+
+> Raw regex patterns like `/movies/(\d+)` are NOT supported in Leaf 5 β patterns without `{}` placeholders are treated as literal paths. Always use named placeholders.
+
+## Optional Parameters
+
+```php
+// /posts and /posts/1 both match β give optional params a default value
+app()->get('/posts/{id?}', function ($id = null) {
+ echo $id ? 'Post #' . $id : 'All posts';
+});
+
+// Optional + constraint: ? goes after the name, before the constraint
+app()->get('/posts/{id?:[0-9]+}', function ($id = null) {
+ echo $id ? 'Post #' . $id : 'All posts';
+});
+
+// Chained optionals
+// Matches /blog, /blog/2024, /blog/2024/01, /blog/2024/01/slug
+app()->get('/blog/{year?:[0-9]{4}}/{month?:[0-9]{2}}/{slug?}', function (
+ $year = null, $month = null, $slug = null
+) {
+ if (!$year) { echo 'Blog overview'; return; }
+ if (!$month) { echo 'Year overview'; return; }
+ if (!$slug) { echo 'Month overview'; return; }
+ echo 'Post: ' . htmlentities($slug);
+});
+```
+
+## Matching Order
+
+Exact routes always beat dynamic routes regardless of registration order (`/users/new` wins over `/users/{id}`). Between overlapping dynamic routes, the one registered first wins β register more specific dynamic routes before broader ones.
+
diff --git a/src/public/ai/references/security.md b/src/public/ai/references/security.md
new file mode 100644
index 00000000..4568e320
--- /dev/null
+++ b/src/public/ai/references/security.md
@@ -0,0 +1,362 @@
+# Leaf 5 β Sessions, Cookies, Flash & Security Reference
+
+## Sessions
+
+```bash
+leaf install session
+```
+
+### Set
+
+```php
+session()->set('firstName', 'John');
+session()->set('user', ['name' => 'John Doe', 'email' => 'john@example.com']);
+session()->set(['firstName' => 'John', 'lastName' => 'Doe']);
+
+// Dot notation β set nested value
+session()->set('user.location', 'Everywhere');
+```
+
+### Get
+
+```php
+$name = session()->get('firstName');
+$name = session()->get('firstName', 'default'); // with default
+$name = session()->get('firstName', 'default', false); // sanitization off
+$name = session()->get(param: 'firstName', sanitize: false); // PHP 8+
+
+// Multiple
+$names = session()->get(['firstName', 'lastName']);
+
+// Dot notation
+$location = session()->get('user.location');
+```
+
+### `retrieve()` β get and delete (flash behavior)
+
+```php
+$name = session()->retrieve('firstName'); // returns value, then removes it
+$name = session()->retrieve('firstName'); // now null
+$name = session()->retrieve('firstName', 'John'); // with default
+```
+
+### Check & Delete
+
+```php
+session()->has('firstName');
+session()->has('user.username'); // dot notation
+
+session()->delete('firstName');
+session()->delete(['firstName', 'lastName']);
+session()->clear(); // delete everything
+
+$all = session()->all();
+```
+
+---
+
+## Flash Messages
+
+Set via response (chainable):
+```php
+response()->withFlash('message', 'Saved!')->redirect('/dashboard');
+response()->withFlash('user', $userObject)->json('...');
+```
+
+Read via request:
+```php
+$message = request()->flash(); // all flash data
+$info = request()->flash('info'); // specific key
+$obj = request()->flash('user');
+```
+
+Remove:
+```php
+flash()->remove('info');
+```
+
+### Toast Notifications (Blade + Tailwind + Alpine)
+
+```php
+return response()
+ ->withFlash('leaf.toast', [
+ 'title' => 'Email verified. Sign in to continue.',
+ 'description' => 'You can now sign in to your account.',
+ 'type' => 'success', // success, danger, warning, info, default
+ ])
+ ->redirect('/dashboard');
+```
+
+In your Blade layout:
+```html
+
+ ...
+ @toastContainer
+
+```
+
+---
+
+## Cookies
+
+```bash
+leaf install cookie
+```
+
+### Set
+
+```php
+// Simple β via response (chainable)
+response()->withCookie('name', 'Fullname');
+response()->withCookie('name', 'Fullname')->withCookie('age', 20)->json(['message' => 'Set']);
+
+// Full options
+cookie()->set('name', 'Fullname', [
+ 'expire' => time() + 3600,
+ 'path' => '/',
+ 'domain' => 'example.com',
+ 'secure' => true,
+ 'httponly' => true,
+ 'samesite' => 'None',
+]);
+```
+
+### Get
+
+```php
+$name = request()->cookies('name');
+$cookies = request()->cookies(['name', 'age']); // ['name' => ..., 'age' => ...]
+$all = request()->cookies();
+```
+
+### Delete
+
+```php
+response()->withoutCookie('name')->json(['message' => 'Deleted']);
+cookie()->delete('name');
+cookie()->deleteAll();
+```
+
+---
+
+## Validation
+
+```bash
+leaf install form
+```
+
+### Basic Usage
+
+```php
+$data = request()->validate([
+ 'title' => 'string|min:5',
+ 'email' => 'email',
+ 'description' => 'optional|string|min:8',
+]);
+
+if (!$data) {
+ $errors = request()->errors();
+}
+```
+
+### Rules with Parameters
+
+```php
+request()->validate([
+ 'bio' => 'min:10',
+ 'age' => 'between:[18,30]',
+ 'role' => 'in:[admin,user,guest]',
+]);
+```
+
+### Indexed Arrays
+
+```php
+request()->validate([
+ 'tags' => 'array',
+ 'prices' => 'array',
+ 'emails' => 'array',
+ 'passwords' => 'array',
+]);
+```
+
+### Associative Arrays / Nested Objects
+
+```php
+request()->validate([
+ 'user.name' => 'string',
+ 'user.age' => ['number', 'optional'],
+]);
+
+// Escape dot if key literally contains a dot
+request()->validate(['user\.name' => 'string']);
+```
+
+### Validate Non-Request Data
+
+```php
+$data = form()->validate(['name' => 'John', 'age' => 25], [
+ 'name' => 'string',
+ 'age' => 'number',
+]);
+
+if (!$data) { $errors = form()->errors(); }
+```
+
+### Custom Error Messages
+
+```php
+request()->validator()->message([
+ 'required' => '{Field} is required', // {Field} capitalizes first letter
+ 'email' => '{field} must be a valid email',
+]);
+```
+
+### Custom Rules
+
+```php
+// Regex
+request()->validator()->rule('isEven', '/^\d*[02468]$/', '{field} must be even.');
+
+// Function
+request()->validator()->rule('isEven', function ($value) {
+ return $value % 2 === 0;
+}, '{field} must be even.');
+
+request()->validate(['number' => 'isEven']);
+```
+
+### Available Rules
+
+| Rule | Description |
+|---|---|
+| `email` | Valid email address |
+| `text` | Alphabetic + spaces ONLY β wrong for passwords or free-form input |
+| `string` | Any string (`is_string`) β use this for passwords and free-form text |
+| `textOnly` | Alphabetic only (no spaces) |
+| `alpha` | Alphabetic characters |
+| `alphaNum` | Alpha-numeric |
+| `alphaDash` | Alpha-numeric + underscores + dashes |
+| `username` | Alpha-numeric + underscores |
+| `number` | Numeric only |
+| `float` | Float values |
+| `date` | Valid date |
+| `min:n` | Minimum value/length |
+| `max:n` | Maximum value/length |
+| `between:[a,b]` | Between two values |
+| `match:value` | Must match value |
+| `contains:value` | Must contain value |
+| `in:[a,b,c]` | Must be in list |
+| `ip` / `ipv4` / `ipv6` | Valid IP address |
+| `url` | Valid URL |
+| `domain` | Valid domain |
+| `creditCard` | Valid credit card number |
+| `phone` | Valid phone number |
+| `uuid` | Valid UUID |
+| `slug` | Valid slug |
+| `json` | Valid JSON string |
+| `regex:pattern` | Must match regex |
+| `optional` | Field not required |
+| `array` | Indexed array of type |
+
+---
+
+## CSRF Protection
+
+```bash
+leaf install csrf
+```
+
+**MVC:** auto-configured after install.
+**Basic:** initialize before routes:
+
+```php
+app()->csrf();
+// ... routes
+```
+
+### Protecting Forms (Blade)
+
+```blade
+
+```
+
+### API / SPA β X-CSRF-Token Header
+
+```js
+fetch('/submit', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': 'YOUR_CSRF_TOKEN',
+ },
+ body: JSON.stringify({ name: 'John Doe' }),
+});
+```
+
+Get the token:
+```php
+$token = csrf()->token();
+```
+
+### Configuration
+
+```php
+// Basic app β inline
+app()->csrf([
+ 'secret' => 'my-secret-key',
+ 'methods' => ['POST', 'PUT', 'PATCH', 'DELETE'],
+ 'except' => ['/webhook', '/api/public'],
+ 'messages.tokenNotFound' => 'Token not found.',
+ 'messages.tokenInvalid' => 'Invalid token.',
+ 'onError' => function ($error) {
+ if ($error === 'tokenNotFound') {
+ return response()->json(['error' => 'No CSRF token'], 403);
+ } else {
+ return response()->json(['error' => 'Invalid CSRF token'], 403);
+ }
+ },
+]);
+
+// MVC β publish config
+// leaf config:publish csrf β config/csrf.php
+// Secret default: derived from APP_KEY automatically.
+// Override: X_CSRF_SECRET=my-secret-key in .env, or 'secret' in config (code wins).
+// No APP_KEY + no secret = RuntimeException at startup. Fix: `php leaf key:generate`
+```
+
+> CSRF is automatically disabled in test mode (`APP_ENV != production`).
+
+---
+
+## Password Hashing
+
+Included in Leaf Auth. Install standalone if needed:
+
+```bash
+leaf install password
+```
+
+```php
+use Leaf\Helpers\Password;
+
+// Add a pepper/spice to all passwords
+Password::spice('#@%7g0!&');
+$spice = Password::spice(); // retrieve current spice
+
+// Hash
+$hash = Password::hash($password); // default algorithm
+$hash = Password::hash($password, Password::BCRYPT);
+$hash = Password::hash($password, Password::ARGON2);
+$hash = Password::bcrypt($password, $options);
+$hash = Password::argon2($password, $options);
+
+// Verify
+if (Password::verify($password, $hash)) { /* authenticated */ }
+if (Password::bcryptVerify($password, $hash)) { /* bcrypt match */ }
+if (Password::argon2Verify($password, $hash)) { /* argon2 match */ }
+```
diff --git a/src/public/ai/references/utilities.md b/src/public/ai/references/utilities.md
new file mode 100644
index 00000000..e42d0b82
--- /dev/null
+++ b/src/public/ai/references/utilities.md
@@ -0,0 +1,165 @@
+# Leaf 5 β Utilities Reference
+
+## Anchor (Security Guard)
+
+Anchor runs automatically in the background β you rarely interact with it directly.
+
+### What it does automatically:
+- **XSS protection** β sanitizes all data coming through `request()`, `session()`, `response()`, etc.
+- **SQL injection protection** β integrates with Leaf DB to auto-escape all query parameters
+- **CSRF** β via the CSRF module (opt-in, see `references/security.md`)
+
+> Anchor's XSS and SQL protection only works with Leaf functions. If you use raw `$_POST`, `$_GET`, `$_REQUEST`, sanitize manually:
+
+```php
+$data = anchor()->sanitize($_POST['data']);
+```
+
+Sanitization β validation. Always validate data too β user input is evil and should never be trusted.
+
+---
+
+## Dates β `tick()`
+
+```bash
+leaf install date
+```
+
+### Creating a Date
+
+```php
+tick(); // now
+tick('2018-01-01 12:00:00'); // from string
+tick(new DateTime('2018-01-01')); // from DateTime object
+```
+
+### Getting & Setting Values
+
+```php
+// Getter (no arg) / Setter (with arg) β returns new tick object
+tick()->year(); tick()->year(2018);
+tick()->month(); tick()->month(6);
+tick()->day(); tick()->day(15); // day of month
+tick()->hour(); tick()->hour(12);
+tick()->minute(); tick()->minute(30);
+tick()->second(); tick()->second(0);
+tick()->millisecond(); tick()->millisecond(0);
+
+// Generic get/set
+tick()->get('year');
+tick()->set('year', 2018);
+```
+
+**Units reference:**
+
+| Unit | Shorthand | Description |
+|---|---|---|
+| `date` | `D` | Day of month |
+| `day` | `d` | Day of week (Sunday=0, Saturday=6) |
+| `month` | `M` | Month (January=0, December=11) |
+| `year` | `y` | Year |
+| `hour` | `h` | Hour |
+| `minute` | `m` | Minute |
+| `second` | `s` | Second |
+| `millisecond` | `ms` | Millisecond |
+
+### Add & Subtract
+
+```php
+tick()->add(1, 'day');
+tick()->add(1, 'week');
+tick()->add(3, 'month');
+tick()->subtract(1, 'day');
+tick()->subtract(2, 'year');
+```
+
+**Add/subtract units:** `day`, `week`, `month`, `year`, `hour`, `minute`, `second`, `millisecond` (shorthand also accepted)
+
+### Start & End Of
+
+```php
+tick()->startOf('month'); // 2024-10-01 00:00:00
+tick()->startOf('year'); // 2024-01-01 00:00:00
+tick()->endOf('month'); // 2024-10-31 23:59:59
+tick()->endOf('year'); // 2024-12-31 23:59:59
+```
+
+**Available units:** `year`, `month`, `week`, `date`/`day`, `hour`, `minute`, `second`
+
+### Chaining
+
+```php
+tick()
+ ->startOf('month')
+ ->add(1, 'day')
+ ->set('year', 2018)
+ ->format('YYYY-MM-DD HH:mm:ss');
+```
+
+### Formatting
+
+```php
+tick()->format(); // ISO8601: '2024-10-03T18:04:37+00:00'
+tick('2019-01-25')->format('DD/MM/YYYY'); // '25/01/2019'
+
+// Escape text in format string with brackets
+tick('2019-01-25')->format('[Today is] DD/MM/YYYY'); // 'Today is 25/01/2019'
+```
+
+**Format tokens:**
+
+| Token | Output | Description |
+|---|---|---|
+| `YYYY` | `2024` | 4-digit year |
+| `YY` | `24` | 2-digit year |
+| `MMMM` | `January` | Full month name |
+| `MMM` | `Jan` | Abbreviated month |
+| `MM` | `01-12` | Month, 2-digit |
+| `M` | `1-12` | Month |
+| `DD` | `01-31` | Day of month, 2-digit |
+| `D` | `1-31` | Day of month |
+| `dddd` | `Sunday` | Full day name |
+| `ddd` | `Sun` | Short day name |
+| `dd` | `Su` | Min day name |
+| `d` | `0-6` | Day of week |
+| `HH` | `00-23` | Hour, 24h, 2-digit |
+| `H` | `0-23` | Hour, 24h |
+| `hh` | `01-12` | Hour, 12h, 2-digit |
+| `h` | `1-12` | Hour, 12h |
+| `mm` | `00-59` | Minute, 2-digit |
+| `m` | `0-59` | Minute |
+| `ss` | `00-59` | Second, 2-digit |
+| `s` | `0-59` | Second |
+| `SSS` | `000-999` | Millisecond, 3-digit |
+| `A` | `AM`/`PM` | AM/PM |
+| `a` | `am`/`pm` | am/pm |
+| `Z` | `+01:00` | UTC offset |
+| `ZZ` | `+0100` | UTC offset (no colon) |
+
+### Relative Time
+
+```php
+tick('2014-10-01')->fromNow(); // '10 years ago'
+tick('2027-10-04')->fromNow(); // 'in 3 years'
+tick('2014-10-01')->fromNow(true); // '10 years' (no ago/in)
+
+tick('2014-10-01')->from('2015-10-01'); // '1 year ago'
+tick('2015-10-01')->from('2014-10-01'); // 'in 1 year'
+tick('2014-10-01')->from('2015-10-01', true); // '1 year' (no ago/in)
+```
+
+### Comparisons
+
+```php
+tick()->isBefore('2011-01-01');
+tick()->isSame(new \DateTime('2011-01-01'));
+tick()->isAfter('2011-01-01');
+
+tick('2010-10-20')->isBetween('2010-10-19', new \DateTime('2010-10-25'));
+tick('2010-10-20')->isBetweenOrEqual('2010-10-19', new \DateTime('2010-10-25'));
+tick('2010-10-20')->isSameDay('2010-10-20');
+tick('2010-10-20')->isSameMonth('2010-10-20');
+tick('2010-10-20')->isSameYear('2010-10-20');
+tick('2000-01-01')->isLeapYear(); // true
+tick()->isDateTime('2000-01-01'); // false
+```
diff --git a/src/public/ai/references/views.md b/src/public/ai/references/views.md
new file mode 100644
index 00000000..69e37221
--- /dev/null
+++ b/src/public/ai/references/views.md
@@ -0,0 +1,445 @@
+# Leaf 5 β Views & Frontend Reference
+
+## Overview
+
+| Engine | Speed | Features | Use Case |
+|---|---|---|---|
+| **BareUI** | β‘οΈ Fast | Basic (pure PHP) | Simple/performance-critical views |
+| **Blade** | Moderate | Rich (`@` directives) | Full-featured templating |
+| **Inertia** | β | React/Vue/Svelte | SPA-style frontend frameworks |
+
+---
+
+## BareUI
+
+Lightweight, pure PHP templating. No compilation, no caching β just speed.
+
+```bash
+leaf install bareui
+```
+
+### Setup (Basic app only β MVC pre-configured)
+
+```php
+app()->template()->config('path', './views');
+```
+
+### Template Files
+
+Extension: `.view.php`
+
+```php
+
+
+```
+
+### Conditional HTML Attributes
+
+```blade
+@class(['p-4', 'font-bold' => $isActive, 'bg-red' => $hasError])
+@style(['background-color: red', 'font-weight: bold' => $isActive])
+@checked($isActive)
+@selected($shouldBeSelected)
+@disabled($shouldBeDisabled)
+@readonly($shouldBeReadonly)
+@required($shouldBeRequired)
+```
+
+### Custom Directives
+
+```php
+app()->blade()->directive('datetime', function ($expression) {
+ return "format('DD MM YYYY'); ?>";
+});
+```
+
+Usage: `@datetime($date)`
+
+---
+
+## Inertia (React / Vue / Svelte)
+
+Connects Leaf backend to a JS frontend framework without building a full API.
+
+### Setup (MVC)
+
+```bash
+leaf view:install --vue
+leaf view:install --react
+leaf view:install --svelte
+```
+
+When you run `view:install`, Leaf automatically generates `app/views/_inertia.blade.php` β the root HTML shell for all Inertia pages. **Do not create this file manually.**
+
+```blade
+{{-- app/views/_inertia.blade.php (auto-generated) --}}
+
+
+
+
+
+ {{ _env('APP_NAME', 'Leaf MVC') }}
+ @viteReactRefresh
+ @vite(['/js/app.jsx', "/js/pages/{$page['component']}.jsx"])
+ @inertiaHead
+
+
+ @inertia
+
+
+```
+
+**This is the right place for global head content** β fonts, analytics (GTM, Tawk.to, OneSignal), favicon, CDN scripts, etc. Edit it when you need those things; otherwise leave it alone.
+
+```blade
+{{-- _inertia.blade.php with global assets --}}
+
+
+
+ @viteReactRefresh
+ @vite(['/js/app.jsx', "/js/pages/{$page['component']}.jsx"])
+ @inertiaHead
+ {{-- GTM, analytics, chat widgets go here --}}
+
+
+ @inertia
+
+```
+
+Key directives:
+- `@viteReactRefresh` β React HMR in dev (must come before `@vite`)
+- `@vite([...])` β loads compiled assets
+- `@inertiaHead` β renders `` tags from React components
+- `@inertia` β mounts the React/Vue/Svelte app
+
+### Returning Inertia Views
+
+```php
+// From controller
+response()->inertia('home', ['user' => auth()->user()]);
+
+// Direct route
+app()->inertia('/home', 'home');
+```
+
+Naming: page files are kebab-case in lowercase folders (`pages/order-history.jsx`), and the string you pass to `response()->inertia()` matches the file name exactly. The component *inside* the file keeps React's PascalCase convention. Prefer `response()->inertia()` over the bare `inertia()` helper β it returns a response like every other handler.
+
+### Generating View Files
+
+```bash
+leaf g:template home # auto-detects framework
+leaf g:template home --type=jsx # React
+leaf g:template home --type=vue
+leaf g:template home --type=svelte
+```
+
+### Shared Data (across all views)
+
+```php
+// app/routes/index.php
+use Leaf\Inertia;
+
+Inertia::share('appName', 'My App');
+Inertia::share('flash', function () {
+ return flash()->display('flash') ?? null;
+});
+```
+
+Leaf automatically shares an `auth` prop with every Inertia page: `{id, user, roles, permissions, errors}`. Do not share your own `auth` key β the framework's value takes precedence and yours never renders. `auth.user` contains every column not listed in auth's `hidden` config.
+
+### React Component Example
+
+```jsx
+export default function Home({ user, appName }) {
+ return
Welcome to {appName}, {user.name}
;
+}
+```
+
+### Form Validation with Inertia
+
+Controller:
+```php
+$data = request()->validate(['email' => 'email']);
+
+if (!$data) {
+ return response()
+ ->withFlash('errors', request()->errors())
+ ->redirect('/form', 303); // 303 is important for Inertia
+}
+```
+
+View (pass errors as prop):
+```php
+response()->inertia('form', [
+ 'errors' => flash()->display('errors') ?? [],
+]);
+```
+
+Frontend (React):
+```jsx
+const { data, setData, patch, errors } = useForm({ email: '' });
+// errors.email auto-populated from the errors prop
+```
+
+### shadcn/ui (React)
+
+```bash
+leaf scaffold:shadcn
+pnpm dlx shadcn@latest add button
+```
+
+---
+
+## Lite Apps
+
+Everything above works in lite apps too, with a few differences in layout:
+
+- `leaf view:install --react` (or `--vue` / `--svelte`) works in lite apps and writes frontend files to `views/js/` in the project root, not `app/views/` β that path belongs to the MVC layout.
+- Lite apps must configure the view paths before rendering. Newly scaffolded apps have this wired automatically as of CLI v5.0.6; older apps need it set by hand:
+
+```php
+app()->config('views.path', 'views');
+app()->config('views.cache', __DIR__ . '/storage/cache');
+```
+
+- Vite serves from the project root in lite apps: the `hot` file and the `build/` directory live at the root, which matches leafs/vite's defaults (5.x latest). MVC apps use `public/` instead, wired automatically.
+- `g:template` and `scaffold:*` commands are MVC-only. They don't exist in a lite app's console, so create page files by hand.
+
+---
+
+## Vite (Asset Bundling)
+
+Pre-installed in MVC. For Basic apps:
+
+```bash
+leaf view:install --vite
+```
+
+### Loading Assets
+
+```blade
+@vite('css/app.css')
+@vite(['app.css', 'app.js'])
+```
+
+PHP (non-Blade):
+```php
+
+```
+
+### `vite.config.js`
+
+```js
+import { defineConfig } from 'vite';
+import leaf from '@leafphp/vite-plugin';
+
+export default defineConfig({
+ plugins: [
+ leaf({
+ input: ['path/to/app.css', 'path/to/app.js'],
+ refresh: true,
+ }),
+ ],
+ resolve: {
+ alias: { '@': '/path/to/folder' },
+ },
+});
+```
+
+### PHP Config (optional, non-MVC)
+
+```php
+\Leaf\Vite::config([
+ 'assets' => 'app/views',
+ 'build' => 'public/build',
+]);
+```
+
+Vite dev server starts automatically with `leaf serve`.
+
+---
+
+## Tailwind CSS
+
+```bash
+leaf view:install --tailwind
+```
+
+Installs Tailwind v4, updates `vite.config.js`, and sets up `css/app.css`.
+
+Include in Blade layout:
+```blade
+@vite('css/app.css')
+```
+
+### Theming (Tailwind v4)
+
+```css
+@theme {
+ --color-primary: #ff0000;
+ --color-secondary: #00ff00;
+}
+```
+
+```html
+
Hello
+```
+
+---
+
+## Custom / Third-Party Template Engines
+
+### Basic App
+
+```php
+app()->attachView(Smarty::class);
+app()->smarty()->setTemplateDir('/views');
+app()->smarty()->assign('name', 'Michael');
+app()->smarty()->display('index.tpl');
+```
+
+### MVC β `config/view.php`
+
+```bash
+leaf config:publish view # β config/view.php
+```
+
+```php
+return [
+ 'viewEngine' => \Smarty::class,
+
+ 'config' => function (\Smarty $engine, array $config) {
+ $engine->setTemplateDir($config['views']);
+ $engine->setCacheDir($config['cache']);
+ },
+
+ 'render' => function (\Smarty $engine, $view, $data) {
+ foreach ($data as $key => $value) {
+ $engine->assign($key, $value);
+ }
+ $engine->display($view);
+ },
+
+ 'extend' => null,
+];
+```
diff --git a/src/public/images/crash-ai-error.png b/src/public/images/crash-ai-error.png
new file mode 100644
index 00000000..7e9b7269
Binary files /dev/null and b/src/public/images/crash-ai-error.png differ
diff --git a/src/public/images/crash/crash-journey.png b/src/public/images/crash/crash-journey.png
new file mode 100644
index 00000000..52d48d46
Binary files /dev/null and b/src/public/images/crash/crash-journey.png differ
diff --git a/src/public/images/crash/crash-screen-dark.png b/src/public/images/crash/crash-screen-dark.png
new file mode 100644
index 00000000..d35f387f
Binary files /dev/null and b/src/public/images/crash/crash-screen-dark.png differ
diff --git a/src/public/images/crash/crash-screen.png b/src/public/images/crash/crash-screen.png
new file mode 100644
index 00000000..8193390f
Binary files /dev/null and b/src/public/images/crash/crash-screen.png differ
diff --git a/src/public/leaf5-alt.png b/src/public/leaf5-alt.png
new file mode 100644
index 00000000..a0cc76d2
Binary files /dev/null and b/src/public/leaf5-alt.png differ
diff --git a/src/public/leaf5-banner.png b/src/public/leaf5-banner.png
new file mode 100644
index 00000000..e2ae660f
Binary files /dev/null and b/src/public/leaf5-banner.png differ
diff --git a/src/public/leaf5-light.png b/src/public/leaf5-light.png
new file mode 100644
index 00000000..657ceeca
Binary files /dev/null and b/src/public/leaf5-light.png differ
diff --git a/src/public/llms.txt b/src/public/llms.txt
new file mode 100644
index 00000000..b4299422
--- /dev/null
+++ b/src/public/llms.txt
@@ -0,0 +1,933 @@
+# 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.
diff --git a/src/public/logo-circle.png b/src/public/logo-circle.png
index 9fbc6130..cb9276c3 100644
Binary files a/src/public/logo-circle.png and b/src/public/logo-circle.png differ
diff --git a/src/public/logo.svg b/src/public/logo.svg
index 8758d301..2e004d8f 100644
--- a/src/public/logo.svg
+++ b/src/public/logo.svg
@@ -1,19 +1 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
\ No newline at end of file
diff --git a/src/public/robots.txt b/src/public/robots.txt
new file mode 100644
index 00000000..fde5f4ee
--- /dev/null
+++ b/src/public/robots.txt
@@ -0,0 +1,4 @@
+User-agent: *
+Allow: /
+
+Sitemap: https://leafphp.dev/sitemap.xml
diff --git a/src/public/sponsors.json b/src/public/sponsors.json
index 5a89de78..fd868db3 100644
--- a/src/public/sponsors.json
+++ b/src/public/sponsors.json
@@ -17,9 +17,9 @@
"img": "https://avatars.githubusercontent.com/u/9919?s=200&v=4"
},
{
- "name": "Kurnia",
- "url": "#",
- "img": "https://images.opencollective.com/guest-da0b50b5/avatar.png"
+ "name": "Claude",
+ "url": "https://claude.com",
+ "img": "https://avatars.githubusercontent.com/u/76263028?s=200&v=4"
}
],
"pastSponsors": [
@@ -33,6 +33,11 @@
"url": "https://www.jetbrains.com/",
"img": "https://avatars.githubusercontent.com/u/878437?s=200&v=4"
},
+ {
+ "name": "FortRabbit",
+ "url": "https://www.fortrabbit.com/",
+ "img": "https://avatars.githubusercontent.com/u/1255897?s=200&v=4"
+ },
{
"name": "Terry",
"url": "https://github.com/terrybr",
@@ -43,6 +48,11 @@
"url": "#",
"img": "https://images.opencollective.com/guest-32634fda/avatar.png"
},
+ {
+ "name": "Kurnia",
+ "url": "#",
+ "img": "https://images.opencollective.com/guest-da0b50b5/avatar.png"
+ },
{
"name": "Casprine Asempah",
"url": "https://github.com/casprine",
@@ -102,6 +112,11 @@
"name": "Kunule Imbayi",
"url": "https://opencollective.com/kunule-imbayi",
"img": "https://images.opencollective.com/kunule-imbayi/avatar.png"
+ },
+ {
+ "name": "Anonymous",
+ "url": "https://opencollective.com/guest-442ddb91",
+ "img": "https://images.opencollective.com/guest-442ddb91/avatar.png"
}
],
"code": [
@@ -449,6 +464,51 @@
"name": "fadrian06",
"url": "https://github.com/fadrian06",
"img": "https://avatars.githubusercontent.com/u/109766973?v=4"
+ },
+ {
+ "name": "Adrian Galicia",
+ "url": "https://github.com/zehntinel",
+ "img": "https://avatars.githubusercontent.com/u/7310519?v=4"
+ },
+ {
+ "name": "Mischa Kroon",
+ "url": "https://github.com/MischaKr",
+ "img": "https://avatars.githubusercontent.com/u/1812985?v=4"
+ },
+ {
+ "name": "Leonardo Carvalho",
+ "url": "https://github.com/srcarvalho12",
+ "img": "https://avatars.githubusercontent.com/u/134537873?v=4"
+ },
+ {
+ "name": "Magdiel Marquez",
+ "url": "https://github.com/mmagdiel",
+ "img": "https://avatars.githubusercontent.com/u/14327365?v=4"
+ },
+ {
+ "name": "Thiago",
+ "url": "https://github.com/ghosthi",
+ "img": "https://avatars.githubusercontent.com/u/85212306?v=4"
+ },
+ {
+ "name": "Rui Trigo",
+ "url": "https://github.com/ruiwashere",
+ "img": "https://avatars.githubusercontent.com/u/148787540?v=4"
+ },
+ {
+ "name": "Andrea Debernardi",
+ "url": "https://github.com/debba",
+ "img": "https://avatars.githubusercontent.com/u/3198901?v=4"
+ },
+ {
+ "name": "lego290",
+ "url": "https://github.com/lego290",
+ "img": "https://github.com/lego290.png"
+ },
+ {
+ "name": "apfox",
+ "url": "https://github.com/apfox",
+ "img": "https://github.com/apfox.png"
}
]
}
diff --git a/src/support.md b/src/support.md
index 86cb7723..8049fc84 100644
--- a/src/support.md
+++ b/src/support.md
@@ -10,9 +10,10 @@ lastUpdated: false
-# Letβs Shape the Future of PHP π
+# Letβs Shape the Future of PHP π§‘
@@ -29,7 +30,7 @@ Weβve helped developers:
Leaf has grown from a small idea into a framework powering apps, businesses, and side projects around the world. And this is just the beginning.
-
+
Now, weβre preparing to take Leaf to the next level. In the coming year, we will:
@@ -51,21 +52,27 @@ Michael from Leaf
## How You Can Support
-π Sponsor on GitHub or OpenCollective
+π§‘ Sponsor on GitHub or OpenCollective
Back Leaf through [GitHub Sponsors](https://github.com/sponsors/leafsphp) or [OpenCollective](https://opencollective.com/leaf). Every contribution β one-time or recurring β fuels development, community events, and new features.
-π Crypto Support
+π§‘ Crypto Support
Prefer crypto? Send USDT (TRC20) to:
`TK6d2w4EqSDsf2xB2SLcEkfUt3vxADtFmp`
+## What Your Support Powers
+
+Supporting Leaf doesn't just fund the framework β it keeps a whole family of tools free, maintained, and moving forward:
+
+
+
## Businesses Using Leaf
Sponsoring Leaf gives you great exposure to all PHP developers around the world through our website and GitHub project READMEs. In addition, supporting OSS improves the reputation of your brand, and also ensures that Leaf stays healthy and actively maintained.
-You can join our [Premium Sponsorship π](https://opencollective.com/leaf/contribute/premium-sponsor-79271) tier which gives you the following perks:
+You can join our [Premium Sponsorship π](/support/premium-sponsorship) tier which gives you the following perks:
- Your logo on our README on GitHub and our website
- A personalized shoutout on our social media channels & newsletters
@@ -77,7 +84,7 @@ Sponsoring Leaf isnβt just giving back β itβs investing in the PHP ecosyst
## Our Sponsors
-We are grateful to all our sponsors, both past and present for their generous support π
+We are grateful to all our sponsors, both past and present for their generous support π§‘
diff --git a/src/support/premium-sponsorship.md b/src/support/premium-sponsorship.md
new file mode 100644
index 00000000..2d742b05
--- /dev/null
+++ b/src/support/premium-sponsorship.md
@@ -0,0 +1,34 @@
+---
+title: "Premium Sponsorship"
+sidebar: false
+ads: false
+editLink: false
+prev: false
+next: false
+lastUpdated: false
+---
+
+# Premium Sponsorship π
+
+Premium sponsorship is how companies back Leaf. It is built for teams that ship products on Leaf, hire from the PHP community, or want their brand in front of PHP developers around the world. That said, it is open to anyone who wants to support at this level.
+
+The tier starts at **$500 a month**, and you choose how far beyond that you want to go. That's it, no hidden expectations. Every cent goes into building and maintaining Leaf, Hanabira, and the tools around them, and toward supporting full-time maintainers so the whole ecosystem stays free and independent.
+
+## What you get
+
+- Your logo on the Leaf README on GitHub and on leafphp.dev
+- A personalized shoutout on our social media channels and newsletters
+- A mention in our monthly progress reports
+- A mention in every release announcement
+- Early access to packages from the team
+- Access to sponsor-only work from the team
+- Access to our sponsors Discord channel
+- Dedicated first-priority support from the Leaf team
+
+## Why companies sponsor
+
+Your engineers get first-priority answers from the people who wrote the code they ship on. Your brand sits in front of every developer who lands on our docs and GitHub projects. And backing open source you rely on is the strongest signal a company can send to the developers it wants to hire.
+
+Become a premium sponsor β
+
+Prefer a different amount, a one-time contribution, or want to talk it through first? Every other way to support lives on [OpenCollective](https://opencollective.com/leaf) and [GitHub Sponsors](https://github.com/sponsors/leafsphp), or reach us on [Discord](https://discord.gg/Pkrm9NJPE3) and we'll sort something out together.
diff --git a/src/tutorial/src/step-13/description.md b/src/tutorial/src/step-13/description.md
index cab2e608..5bdbd9d9 100644
--- a/src/tutorial/src/step-13/description.md
+++ b/src/tutorial/src/step-13/description.md
@@ -86,7 +86,7 @@ $app->post('/register', function () use($app, $auth) {
$user = $auth->register($userData);
if (!$user) {
- response()->exit([
+ return response()->exit([
'status' => 'error',
'message' => 'Registration failed',
'data' => $auth->errors(),
@@ -110,14 +110,14 @@ app()->post('/register', function () {
$user = auth()->register($userData);
if (!$user) {
- response()->exit([
+ return response()->exit([
'status' => 'error',
'message' => 'Registration failed',
'data' => auth()->errors(),
]);
}
- response()->json([
+ return response()->json([
'status' => 'success',
'message' => 'Registration successful',
'data' => $user,
diff --git a/src/tutorial/src/step-14/description.md b/src/tutorial/src/step-14/description.md
index 84833728..a53b1adb 100644
--- a/src/tutorial/src/step-14/description.md
+++ b/src/tutorial/src/step-14/description.md
@@ -16,7 +16,7 @@ $app->post('/login', function () use($app, $auth) {
$user = $auth->login($userData);
if (!$user) {
- response()->exit([
+ return response()->exit([
'status' => 'error',
'message' => 'Login failed',
'data' => $auth->errors(),
@@ -40,14 +40,14 @@ app()->post('/login', function () {
$user = auth()->login($userData);
if (!$user) {
- response()->exit([
+ return response()->exit([
'status' => 'error',
'message' => 'Login failed',
'data' => auth()->errors(),
]);
}
- response()->json([
+ return response()->json([
'status' => 'success',
'message' => 'Login successful',
'data' => $user,
diff --git a/src/tutorial/src/step-4/description.md b/src/tutorial/src/step-4/description.md
index a73fb2e5..c60a4bff 100644
--- a/src/tutorial/src/step-4/description.md
+++ b/src/tutorial/src/step-4/description.md
@@ -27,9 +27,7 @@ You'll notice that although we're outputting html, the browser renders JSON beca
require __DIR__ . '/vendor/autoload.php';
-app()->get('/', function () {
- response()->markup('something');
-});
+app()->get('/', fn () => response()->markup('something'));
app()->run();
```
@@ -49,9 +47,7 @@ The JSON method takes in some data to output and the [http status code](https://
require __DIR__ . '/vendor/autoload.php';
-app()->get('/', function () {
- response()->json('something');
-});
+app()->get('/', fn () => response()->json('something'));
app()->run();
```
@@ -70,9 +66,7 @@ Leaf provides a `markup()` method that allows you to output HTML. This method is
require __DIR__ . '/vendor/autoload.php';
-app()->get('/', function () {
- response()->markup('something');
-});
+app()->get('/', fn () => response()->markup('something'));
app()->run();
```
@@ -92,9 +86,7 @@ Sometimes, simply being able to output html or PHP isn't enough. You may have so
require __DIR__ . '/vendor/autoload.php';
-app()->get('/', function () {
- response()->page('./index.html');
-});
+app()->get('/', fn () => response()->page('./index.html'));
app()->run();
```
@@ -120,7 +112,7 @@ app()->get('/', function () {
// nothing below will run if exit is executed
}
- response()->markup('folder found');
+ return response()->markup('folder found');
});
app()->run();
diff --git a/src/tutorial/src/step-5/description.md b/src/tutorial/src/step-5/description.md
index 860b0734..da9142c5 100644
--- a/src/tutorial/src/step-5/description.md
+++ b/src/tutorial/src/step-5/description.md
@@ -12,7 +12,7 @@ require __DIR__ . '/vendor/autoload.php';
// for a get request
app()->get('/', function () {
$data = request()->get('name');
- response()->json($data);
+ return response()->json($data);
});
app()->run();
@@ -33,7 +33,7 @@ require __DIR__ . '/vendor/autoload.php';
app()->get('/', function () {
$data = request()->body();
- response()->json($data);
+ return response()->json($data);
});
app()->run();
@@ -52,7 +52,7 @@ require __DIR__ . '/vendor/autoload.php';
app()->get('/', function () {
$data = request()->get('name');
- response()->json($data);
+ return response()->json($data);
});
app()->run();
@@ -78,7 +78,7 @@ require __DIR__ . '/vendor/autoload.php';
app()->get('/', function () {
$data = request()->get(['name', 'country']);
- response()->json($data);
+ return response()->json($data);
});
app()->run();
diff --git a/src/tutorial/src/step-6/description.md b/src/tutorial/src/step-6/description.md
index 1fdd1e74..4efb01a1 100644
--- a/src/tutorial/src/step-6/description.md
+++ b/src/tutorial/src/step-6/description.md
@@ -11,9 +11,7 @@ Although this sounds super complex, leaf makes it really easy to do this. Let's
require __DIR__ . '/vendor/autoload.php';
-app()->get('/users/{id}', function () {
- response()->markup('hello world');
-});
+app()->get('/users/{id}', fn () => response()->markup('hello world'));
app()->run();
```
@@ -27,9 +25,7 @@ You can try `/users/anything` and you'll see that the route still works. The rou
require __DIR__ . '/vendor/autoload.php';
-app()->get('/users/{id}', function ($id) {
- response()->markup("This is user $id");
-});
+app()->get('/users/{id}', fn ($id) => response()->markup("This is user $id"));
app()->run();
```
@@ -63,9 +59,7 @@ Let's see how we can make sure that our route in the first example only supports
require __DIR__ . '/vendor/autoload.php';
-app()->get('/users/(\d+)', function ($id) {
- response()->markup("The number passed in is: $id");
-});
+app()->get('/users/(\d+)', fn ($id) => response()->markup("The number passed in is: $id"));
app()->run();
```
diff --git a/src/tutorial/src/step-7/description.md b/src/tutorial/src/step-7/description.md
index 2082b036..a5444fe0 100644
--- a/src/tutorial/src/step-7/description.md
+++ b/src/tutorial/src/step-7/description.md
@@ -41,7 +41,7 @@ app()->cors();
app()->get('/', function () {
$data = request()->get('name');
- response()->json($data);
+ return response()->json($data);
});
app()->run();
@@ -66,7 +66,7 @@ app()->cors([
app()->get('/', function () {
$data = request()->get('name');
- response()->json($data);
+ return response()->json($data);
});
app()->run();
diff --git a/src/tutorial/src/step-8/description.md b/src/tutorial/src/step-8/description.md
index 6e2bd99a..ddd0b8f7 100644
--- a/src/tutorial/src/step-8/description.md
+++ b/src/tutorial/src/step-8/description.md
@@ -72,7 +72,7 @@ app()->get('/', function () {
'country' => 'string',
]);
- response()->json(
+ return response()->json(
$validatedData ?: request()->errors()
);
});
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 90807c96..a1436998 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -1,5 +1,5 @@
-const animate = require('tailwindcss-animate');
-const headlessui = require('@headlessui/tailwindcss');
+import animate from 'tailwindcss-animate';
+import headlessui from '@headlessui/tailwindcss';
/** @type {import('tailwindcss').Config} */
export default {
@@ -69,12 +69,38 @@ export default {
from: { height: 'var(--radix-collapsible-content-height)' },
to: { height: 0 },
},
+ blink: {
+ '0%, 49%': { opacity: 1 },
+ '50%, 100%': { opacity: 0 },
+ },
+ 'fade-slide-in': {
+ from: { opacity: 0, transform: 'translateY(4px)' },
+ to: { opacity: 1, transform: 'translateY(0)' },
+ },
+ 'gradient-pan': {
+ '0%': { backgroundPosition: '0% 50%' },
+ '50%': { backgroundPosition: '100% 50%' },
+ '100%': { backgroundPosition: '0% 50%' },
+ },
+ 'float-soft': {
+ '0%, 100%': { transform: 'translateY(0)' },
+ '50%': { transform: 'translateY(-6px)' },
+ },
+ 'pulse-soft': {
+ '0%, 100%': { opacity: 0.45 },
+ '50%': { opacity: 1 },
+ },
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'collapsible-down': 'collapsible-down 0.2s ease-in-out',
'collapsible-up': 'collapsible-up 0.2s ease-in-out',
+ blink: 'blink 1s steps(1) infinite',
+ 'fade-slide-in': 'fade-slide-in 0.3s ease-out',
+ 'gradient-pan': 'gradient-pan 8s ease infinite',
+ 'float-soft': 'float-soft 5s ease-in-out infinite',
+ 'pulse-soft': 'pulse-soft 2.6s ease-in-out infinite',
},
},
},