- 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 is a PHP framework designed to stay light, while giving you production-ready pieces to handle all the heavy-lifting as soon as you need them. We rebuilt the Leaf experience with AI in mind, so you and your agents can build at the speed of thought hehe
+ 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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You read
+ app/controllers/OrdersController.php
+
+ 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.
-
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..1abac1e9 100644
--- a/src/docs/frontend/blade.md
+++ b/src/docs/frontend/blade.md
@@ -2,29 +2,7 @@
-
-
-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 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 +66,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.
+Leaf works with simple PHP views, Blade, BareUI, Vite, Tailwind, Inertia, React, Vue, Svelte, and third-party engines. Your backend stays the same whichever you pick.
## 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..2eb0b6e2 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..93b448b4 100644
--- a/src/docs/modules.md
+++ b/src/docs/modules.md
@@ -1,66 +1,133 @@
# 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.
+
+
+Modules are pieces of Leaf functionality that can be added to your application to extend its capabilities. Unlike many other frameworks, Leaf ships very light out of the box and provides all extra functionality through modules. This allows you to keep your core application lean while only adding the features you need.
## 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
+
+
+ 02 / Storage
+ Data and state
+ Database, Redis, cache, queues, files, sitemaps, 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, 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..70b9b180 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 an MVC structure (controllers, models, views) and a full CLI.
---
# Leaf + MVC
@@ -8,188 +10,67 @@ 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 and cache, as well as 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..6a6108e7 100644
--- a/src/docs/mvc/scaffolds.md
+++ b/src/docs/mvc/scaffolds.md
@@ -1,88 +1,285 @@
# 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
+Leaf MVC scaffolding creates complete feature starting points: routes, controllers, models, schema files, views, and middleware that match your app setup. You run a command, then edit real files you own.
+
+
+
+
+
Commands
+
+
$ leaf scaffold:auth
+
$ leaf scaffold:blog
+
$ leaf scaffold:landing-page
+
$ leaf scaffold:waitlist
+
$ leaf scaffold:contact
+
$ leaf scaffold:mail
+
$ leaf scaffold:legal
+
$ leaf scaffold:ai
+
$ leaf scaffold:shadcn
+
$ leaf scaffold:subscriptions with leafs/billing
+
+
+
+
scaffold:auth generates
+
+
app/routes/_auth.php
+
app/controllers/Auth/ login, register, dashboard
+
app/controllers/Profile/ account, updates
+
app/views/pages/auth/ login, register
+
app/views/pages/dashboard
+
app/views/layouts + components
+
views match your setup: blade, react, vue, svelte, or api-only
+
+
+
+
+
+## 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, 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.
+
+
+
+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.
+
+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 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.
+## Authentication
-You can get started using the `scaffold:auth` command:
+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
+
+
+ 01 / Accounts
+ Backend
+ User model with its schema file, controllers for login, register, dashboard and account updates, and auth routes with protection middleware applied.
+
+
+ 02 / Screens
+ Frontend
+ Login, register, dashboard and profile views in your frontend setup. The full file list is in the panel at the top of this page.
+
+
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
```
-You get:
-
-- A structured homepage layout
-- Sections like hero, features, and footers
-- Tailwind for styling + your preferred frontend setup
-- Easy customization with Leaf Zero components
-
-
+
+
+ 01 / Page
+ Homepage
+ A structured homepage layout with hero, feature and footer sections, styled with Tailwind in your frontend setup.
+
+
+ 02 / Customize
+ Leaf Zero components
+ Sections are built from Leaf Zero components, so they are easy to restyle and rearrange.
+
+
## 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:
-
-- A pricing component in whatever frontend setup you are using
-- Subscription/cancellation controllers
-- Webhooks/callbacks/routes for Stripe
-- Database schema, models and config
+It requires [Leaf Billing](/docs/utils/billing) to be installed.
-
+
+
+ 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
```
-These will give you:
+
+
+ 01 / Collection
+ Frontend
+ A waitlist component for collecting emails in your frontend setup.
+
+
+ 02 / Control
+ Backend
+ Models and schema files for email collection, middleware that restricts accidental access to your app, and waitlist invites as 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
+```
+
+
+
+ 01 / Writing
+ Markdown posts
+ A posts folder (app/blog) you publish to by dropping in markdown files with title, date and description frontmatter. Parsedown is installed for you.
+
+
+ 02 / Reading
+ Frontend
+ Blog index and post pages in your frontend setup, with controllers and routes for listing and reading posts.
+
+
+
+## Contact form
+
+A contact form that actually sends mail, wired end to end.
+
+```bash:no-line-numbers
+leaf scaffold:contact
+```
+
+
+
+ 01 / Form
+ Frontend
+ A contact page in your frontend setup, with routes wired up and ready to restyle.
+
+
+ 02 / Delivery
+ Leaf Mail
+ A controller that validates submissions and sends the message with Leaf Mail, installed and configured for you if missing. CONTACT_EMAIL lands in your .env, 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
+```
+
+
+
+ 01 / Pages
+ Frontend
+ Privacy policy and terms of service pages in your frontend setup, wired to your APP_NAME and CONTACT_EMAIL env values.
+
+
+ 02 / Editing
+ Your part
+ 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
+```
+
+
+
+ 01 / Chat UI
+ Frontend
+ A chat interface in your frontend setup with streaming responses.
+
+
+ 02 / Server
+ Anthropic proxy
+ Routes that proxy to the Anthropic API. ANTHROPIC_API_KEY is added to your .env; 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
-- 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
+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..66027b72 100644
--- a/src/docs/routing/index.md
+++ b/src/docs/routing/index.md
@@ -2,52 +2,28 @@
-
-
-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.
-
-
+Leaf routes define the URL, HTTP method, and handler for each request. The API stays small enough to read quickly, and still covers named routes, redirects, 404 handling, dynamic routes, middleware, groups, and MVC controllers.
## 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 +140,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 +167,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..b3041aab 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,5 @@ 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: [Go to MVC middleware →](/docs/routing/middleware/mvc)
-
diff --git a/src/docs/routing/middleware/mvc.md b/src/docs/routing/middleware/mvc.md
index f0a87e6a..9be84392 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
- Learn more about routing in Leaf MVC, dynamic routes, middleware and more.
+ Learn more about routing in Leaf MVC, including dynamic routes and middleware.
-
-
-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.
-
-
-
-
-
-
-
+::: tip 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
@@ -95,6 +85,8 @@ If the CSRF token is missing or invalid, the CSRF module will throw an exception
To protect your forms from CSRF attacks, you can add the CSRF token to your forms. The CSRF module provides a beautiful `form()` method that generates a hidden input field with the CSRF token.
+Note that the Blade `@csrf` directive is a no-op until this module is installed: templates can include it ahead of time and it starts rendering the token field once the module is in your app. To confirm protection is active, check your rendered form for the hidden token input.
+
::: code-group
```blade{2} [Leaf Blade]
@@ -138,9 +130,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 +198,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`.
+:::
-If you have an environment file, you can set the secret key there.
+## The Encryption Secret
+
+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..3bd67121 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), or [OpenSwoole](https://openswoole.com) change that: your app boots once, stays in memory, and handles many requests concurrently. That means faster responses, and room for WebSockets, timers, or 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..3439ad6a 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
---
-
-
-
-
-
- Leaf MVC is a minimalistic PHP framework built for developers who need a simple and elegant toolkit to create full-featured web applications.
-
-
-
-
-
-
+Build APIs that power your mobile apps, frontends, and third-party integrations, with JSON responses and auth ready when you need them.
-You might choose to separate your API logic from your frontend, with a mobile app or a framework like React, Vue, or Svelte consuming your API. This approach keeps things modular, letting you build once and serve multiple clients.
+
-While you can build APIs with the basic Leaf setup, Leaf MVC provides a structured way to do it. It follows the Model-View-Controller (MVC) pattern, but instead of traditional views, you return JSON or XML responses—keeping things clean, simple, and fast. And, of course, you still get all the power and flexibility Leaf brings.
+## Start from a working API
-## Getting started
+Out of the box, you can build:
-You can create a new MVC API app using the `create` command on the Leaf CLI.
+- User authentication (login, signup, JWT)
+- Mobile app backends
+- Third-party integrations
+- Payment webhooks
+- Frontend APIs (for React, Vue, Svelte)
-```bash:no-line-numbers [Leaf CLI]
-leaf create my-app --api
-```
+There's no wiring to do, and no guessing about structure.
-This command sets up a new Leaf MVC app in the my-app directory, optimized for building APIs. It removes default views, configures JSON responses by default, and includes a few console tweaks to streamline API development. Once it's ready, navigate to your app directory and start the server.
+## Your API is ready for AI
-```bash:no-line-numbers
-cd my-app
-leaf serve
-```
+Leaf MVC gives agents shared project memory out of the box. Open an agent in the project and it reads `.leaf/CONTEXT.md` alongside your routes, controllers, models, and database files, then syncs useful structural changes back into the context.
+
+Tell it:
+
+> "Add a stripe webhook endpoint"
+>
+> "Create a user authentication API"
+>
+> "Build a todo list API"
+
+No AI setup or context command is required.
-Your app is now running! Open `http://localhost:5500` in your browser.
+If you use an external assistant without access to the project folder, run `leaf context` and paste the compact output into your conversation.
## Project Structure
-Leaf MVC, like the rest of Leaf, is built for makers—focused, lightweight, and flexible. It keeps only the essentials, giving you everything you need without the extra baggage. Here’s the basic structure of a Leaf MVC app:
+Leaf organizes your API into a simple, convention-based structure:
+
+- **Routes**: Define your endpoints
+- **Controllers**: Handle requests and return JSON
+- **Models**: Interact with your database
+- **Database**: Your schema and migrations
```bash:no-line-numbers
├───app
@@ -66,162 +62,11 @@ Leaf MVC, like the rest of Leaf, is built for makers—focused, lightweight, and
└───public
```
-Leaf MVC keeps things simple, ensuring you focus on building rather than configuration. Here’s a quick breakdown of the key directories:
-
-- 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 images. This is the only directory exposed to the browser.
-
-Most of your work will happen in the app directory, with a typical request starting with a route, which calls a controller, which interacts with a model, and finally renders a view—this is the MVC cycle in action.
-
-Don’t worry if you’re new to MVC! Just remember: every request starts with a route and ends with a JSON or XML response.
-
-## Building your first app
-
-As a maker, the easiest way to get started with your app is by building a Coming Soon, Early Access, or Pre-Launch page. This gives you something real to share while you build, helping you gather interest and early users. Let’s create a simple API that collects emails for a pre-launch page.
-
-### The routing bit
-
-In Leaf MVC, routes are defined in the `app/routes` directory. You’ll find an index.php file along with some files that start with `_`. These are partials, and Leaf automatically loads them to help you organize routes in a way that fits your project.
-
-For our pre-launch page, let’s create a new route inside `app/routes/_prelaunch.php`. Open the file and add this:
-
-```php
-post('/prelaunch', 'SubscribersController@store');
-```
-
-Okay, let's break it down:
-
-- `app()` is a helper function that gives you access to the Leaf app instance, it is available from anywhere in your app.
-- `post()` is a method that limits the route to POST requests only. The first argument is the route, and the second is the controller and method that will handle the request.
-
-### Handling the form submission
-
-This is the most important piece of our pre-launch API. We need to create a route that calls a controller, validates the email, and saves it to a database using a model. This will take us through the full MVC cycle, plus a bit of setup.
-
-Let’s start by generating the controller we defined in our route, we'll use the console for this:
-
-```bash:no-line-numbers
-leaf g:controller subscribers
-```
-
-This will create a new controller in the `app/controllers` directory. Open the `SubscribersController.php` file and add the `store` method.
-
-```php [app/controllers/SubscribersController.php]
-validate(['email' => 'email'])) { // [!code ++]
- return response()->json(['status' => 'error', 'data' => request()->errors()], 400); // [!code ++]
- } // [!code ++]
-
- // save the email
- }
-}
-```
-
-This validates the entered email and returns an error if it's not a valid email. Great job so far! Now, let's save the email to a database using a model.
-
-### Working with the database
-
-First, we'll generate a Subscriber model using the console:
-
-```bash:no-line-numbers
-leaf g:model subscriber
-```
-
-We don’t need to modify the model—Leaf keeps things simple. But before we can store anything, we need to connect our database. Open your .env file and add your database credentials:
-
-```env
-DB_CONNECTION=mysql
-DB_HOST=xxx
-DB_PORT=xxx
-DB_DATABASE=xxx
-DB_USERNAME=xxx
-DB_PASSWORD=xxx
-```
-
-After updating your credentials, restart your server with: `leaf serve`.
-
-Now, we need to create our database table. Leaf MVC makes this seamless with schema files, a simpler way to define and manage your database structure. Let’s set up our schema next!
-
-```bash:no-line-numbers
-leaf g:schema subscribers
-```
-
-The name of the schema file should be the same as your table name. This will create a new schema file in the `app/database` directory. Open the file and add the columns you want in your table.
-
-```php [app/database/subscribers.yml]
-columns:
- email: string
-```
-
-Here, we are telling Leaf to add a column where we can store the email. We can now run the migration to create the table.
-
-```bash:no-line-numbers
-leaf db:migrate
-```
-
-### Saving the email
-
-We can now save the email to the database using our `Subscriber` model.
-
-```php [app/controllers/SubscribersController.php]
-validate(['email' => 'email'])) {
- return response()->json(['status' => 'error', 'data' => request()->errors()], 400);
- }
-
- $subscriber = new Subscriber; // [!code ++]
- $subscriber->email = $data['email']; // [!code ++]
- $subscriber->save(); // [!code ++]
-
- return response()->json([ // [!code ++]
- 'status' => 'success', // [!code ++]
- 'data' => $data // [!code ++]
- ]); // [!code ++]
- }
-}
-```
-
-We can now test this by sending a POST request to `http://localhost:5500/prelaunch` with an email parameter. If everything is set up correctly, you should see a success message with the email you sent.
-
-### Deploying your app
+## Ready for production
-We have built a simple pre-launch page using Leaf MVC. 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/).
+Once your API is built, tested, and ready, deploy it to [Heroku](/learn/deployment/heroku/), [Fly.io](/learn/deployment/flyio/), [DigitalOcean](/learn/deployment/digitalocean/), or any shared hosting service.
-For your frontend app, you can consider managed services like [Netlify](https://netlify.com), [Vercel](https://vercel.com), or [GitHub Pages](https://pages.github.com/) which offer one-click deployments.
+For your frontend, upload to Google PlayStore or the Apple App Store, use [Netlify](https://netlify.com), [Vercel](https://vercel.com), or [GitHub Pages](https://pages.github.com/) for web apps.
- Learn more about routing in Leaf MVC, dynamic routes, middleware and more.
+ Learn more about routing in Leaf, including dynamic routes and middleware.
+Some projects are just a few pages, a webhook, or a small API. Leaf is designed to let you start with the simplest structure that works and grow as the product needs it.
+
-
-
-
-
-
- Use Leaf as a lightweight micro-framework with no structure to build simple applications and APIs.
-
-
-
-
-
-
-
-
-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.
+Need more structure? `leaf create my-app --mvc` gives you controllers, views and models up front. Everything else stays optional.
-```bash:no-line-numbers
-composer require leafs/leaf
-```
+## The smallest useful Leaf app
-You can then create a new Leaf app by creating a new `index.php` file and requiring the Leaf autoloader.
+A lite project starts with enough structure to handle real requests without putting a framework ceremony between you and the product.
-```php:no-line-numbers [index.php]
+```php
get('/', function() {
- response()->json(['message' => 'Hello, World!']);
-});
+app()->get('/', fn () => response()->json([
+ 'message' => 'Hello from Leaf'
+]));
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
-```
-
-```bash:no-line-numbers [Composer]
-composer require leafs/blade:v4
-```
-
-:::
-
-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.
-
-
-
-```
-
-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.
-
-```php [index.php]
-get('/', function() {
- response()->json(['message' => 'Hello, World!']);
-});
-
-app()->view('/prelaunch', 'prelaunch'); // [!code ++]
-
-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');
+
+
+ 01 / Route
+ Match the request
+ Connect an HTTP method and URL to the behavior your application needs.
+
+
+ 02 / Logic
+ Write the useful part
+ When your route matches, implement the necessary logic to handle the request.
+
+
+ 03 / Response
+ Return clean output
+ Send JSON, HTML, redirects, downloads, or any response the client expects.
+
+
-app()->post('/store', function () {
- if (!$data = request()->validate(['email' => 'email'])) {
- // validation failed, redirect back with errors
- return response()
- ->withFlash('errors', request()->errors())
- ->redirect('/prelaunch');
- }
+## Working with an AI assistant
- // save the email
-});
+Leaf CLI creates a `.leaf/CONTEXT.md` file with context about your project. When using a coding assistant in your editor or terminal, ask it to read that file before making changes.
-app()->run();
-```
+For example, if you tell your agent to implement a new messaging feature, it will automatically read the context from `.leaf/CONTEXT.md` and make changes that fit the existing project structure, and if your assistant cannot access the project folder, run:
-::: details Creating your database
-
-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.
-
-```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
+Paste the output into your conversation along with your request. See [AI in Leaf](/docs/ai) for more on project context.
-We can now save the email to the database.
+## Add capabilities when the product asks
-```php [index.php]
-connect([
- 'host' => '127.0.0.1',
- 'username' => 'root',
- 'password' => '',
- 'dbname' => 'Leaf',
-]);
-
-app()->get('/', function() {
- response()->json(['message' => 'Hello, World!']);
-});
-
-app()->view('/prelaunch', 'prelaunch');
+```bash:no-line-numbers
+leaf install auth db mail
+```
+
+
-app()->post('/store', function () {
- if (!$data = request()->validate(['email' => 'email'])) {
- // validation failed, redirect back with errors
- return response()
- ->withFlash('errors', request()->errors())
- ->redirect('/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.
+
+
- db()->insert('emails')->params($data)->execute(); // [!code ++]
+All three stages stay inside Leaf, so growth does not require a framework rewrite.
- return response() // [!code ++]
- ->withFlash('success', 'You have been added to our list!') // [!code ++]
- ->redirect('/prelaunch'); // [!code ++]
-});
+## Deploy anywhere PHP runs
-app()->run();
-```
+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.
-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..1b1f4807 100644
--- a/src/learn/index.md
+++ b/src/learn/index.md
@@ -6,137 +6,60 @@ 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.
-
-
-
-
-
-
-
- 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.
-
-
-
-
-
-
-
-
+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:
+
+
+## Build with AI, not against it
+
+Leaf 5 is designed to work with AI, not fight it.
+
+Tell your AI:
+
+>“Add authentication”
+>
+>“Create a dashboard”
+>
+>“Add Stripe billing”
+
+And it has everything it needs to build it correctly.
+
## Deploy your app
After building your app, you'll want to deploy it to the web so that others can access it. We've got you covered with our [deployment guides](/learn/deployment/) that walk you through deploying your app to various platforms.
diff --git a/src/docs/migrating.md b/src/learn/migrating.md
similarity index 85%
rename from src/docs/migrating.md
rename to src/learn/migrating.md
index f5e95577..1bb0a7b4 100644
--- a/src/docs/migrating.md
+++ b/src/learn/migrating.md
@@ -8,7 +8,7 @@ Before you go on, we just want to say
## Why Migrate to Leaf?
-Depending on the framework you're coming from, you might have different reasons for migrating to Leaf. Leaf is lightweight, modular, and has a simple API. It offers better performance and flexibility compared to many other frameworks. Leaf also allows you to integrate other libraries seamlessly into your Leaf apps with no conflicts or complexities.
+Depending on the framework you're coming from, you might have different reasons for migrating to Leaf. Leaf is lightweight and modular, with a simple API and better performance and flexibility than many other frameworks. You can also bring other libraries into your Leaf apps without conflicts or complexities.
We are still in the process of creating migration guides for different frameworks. If you have a specific framework you'd like to migrate from, please let us know by creating an issue on our GitHub repository. For now, you can follow the general guide below.
@@ -41,11 +41,11 @@ Slim and Leaf are both micro-frameworks, so the migration process is relatively
::: code-group
```bash:no-line-numbers [Leaf CLI]
-leaf install leaf@v4.0-beta
+leaf install leaf@5.0
```
```bash:no-line-numbers [Composer]
-composer require leafs/leaf:4.0-beta
+composer require leafs/leaf:^5.0
```
:::
@@ -54,7 +54,9 @@ We can start off by swapping out the Slim request and response objects with Leaf
## Replacing HTTP Interfaces
-Now, we can replace Slim's request and response objects with Leaf's. What makes this process easy is that Leaf's request and response objects are not tied to any specific framework. This means you can use them in any PHP application. They use PHP's internal methods which makes them compatible with any PHP application.
+Now, we can replace Slim's request and response objects with Leaf's. What makes this process easy is that Leaf's request and response objects are not tied to any specific framework.
+
+That means you can use them in any PHP application. They use PHP's internal methods which makes them compatible with any PHP application.
```php
run();
## Replacing Router Interfaces
-We've replaced the request and response objects, but we still need to replace the router. Leaf's router is an extremely powerful and flexible router that can handle any type of route. Since we already installed the `leaf` module, we can start using Leaf's router.
+We've replaced the request and response objects, but we still need to replace the router. Leaf's router is flexible enough to handle any type of route, and since we already installed the `leaf` module, we can start using it right away.
```php
get('/', function () {
$name = request()->get('name');
- response()->markup("Hello, $name");
+ return response()->markup("Hello, $name");
});
$app->run();
@@ -152,7 +154,7 @@ require __DIR__ . '/../vendor/autoload.php';
app()->get('/', function () {
$name = request()->get('name');
- response()->markup("Hello, $name");
+ return response()->markup("Hello, $name");
});
app()->run();
diff --git a/src/learn/monitoring/tracking.md b/src/learn/monitoring/tracking.md
index 3a231919..b6450082 100644
--- a/src/learn/monitoring/tracking.md
+++ b/src/learn/monitoring/tracking.md
@@ -44,14 +44,14 @@ composer require mixpanel/mixpanel-php
:::
-Next, you can initialize the Mixpanel SDK in your Leaf MVC application. You can do this in the `app/routes/index.php` file, where you can set up the Mixpanel client with your project token. We're using this file because it's loaded before any routes are defined, ensuring that the Mixpanel client is available throughout your application. Think of it like a service provider in other frameworks.
+Next, you can initialize the Mixpanel SDK in your Leaf MVC application. You can do this in the `app/routes/index.php` file, where you can set up the Mixpanel client with your project token.
+
+We're using this file because it's loaded before any routes are defined, ensuring that the Mixpanel client is available throughout your application. Think of it like a service provider in other frameworks.
```php:no-line-numbers [app/routes/index.php]
-app()->register('mixpanel', function () {
- return Mixpanel::getInstance(
- _env('MIXPANEL_TOKEN'),
- );
-});
+app()->register('mixpanel', fn () => Mixpanel::getInstance(
+ _env('MIXPANEL_TOKEN'),
+));
```
This will register the Mixpanel client directly in the Leaf container, allowing you to access it anywhere in your application using `app()->mixpanel`. With this setup, you can now track events and user interactions in your Leaf MVC application.
diff --git a/src/learn/mvc.md b/src/learn/mvc.md
index e3f95eee..a89213b6 100644
--- a/src/learn/mvc.md
+++ b/src/learn/mvc.md
@@ -3,500 +3,143 @@ next: false
prev: false
---
-# Building full-stack with Leaf MVC
+# Build with Leaf MVC
+When your app needs separate places for routes, request handling, data, and templates, start with Leaf MVC. It uses the same Leaf APIs as the basic setup, with a directory structure for organizing your code.
+
-
-
-
-
-
-
- Leaf MVC is a minimalistic PHP framework built for developers who need a simple and elegant toolkit to create full-featured web applications.
-
-
-
-
-
-
-
-
-Full-stack applications typically combine both the front-end and back-end in a single, cohesive system. With Leaf MVC, you get all the power of Leaf for handling requests while seamlessly rendering views using [Blade](/docs/frontend/blade) or integrating with modern front-end frameworks like [React, Vue or Svelte](/docs/frontend/inertia).
-
-## Getting started
-
-You can create a new MVC app using the `create` command on the Leaf CLI.
-
-::: code-group
-
-```bash:no-line-numbers [Leaf CLI]
-leaf create my-app --mvc
-```
-
-```bash:no-line-numbers [Composer]
-composer create-project leafs/mvc:v4.0-beta my-app
-```
-
-:::
-
-This will create a new Leaf MVC app in the `my-app` directory. You can then navigate to the `my-app` directory and run the app using the `serve` command.
-
-```bash:no-line-numbers
-cd my-app
-leaf serve
-```
+
-Your app is now running! Open `http://localhost:5500` in your browser.
+Building a small API or a few pages? The [basic setup](/learn/basic) is a simpler starting point.
-## Project Structure
+## Your first controller and route
-Leaf MVC, like the rest of Leaf, is built for makers—focused, lightweight, and flexible. It keeps only the essentials, giving you everything you need without the extra baggage. Here’s the basic structure of a Leaf MVC app:
+Controllers group the methods that handle requests. Create one for users:
```bash:no-line-numbers
-├───app
-│ ├── controllers
-│ ├── database
-│ ├── models
-│ ├── routes
-│ └── views
-│ └── errors
-└───public
- └───assets
- ├── css
- └── img
+leaf g:controller users
```
-Leaf MVC keeps things simple, ensuring you focus on building rather than configuration. Here’s a quick breakdown of the key directories:
-
-- 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.
-
-Most of your work will happen in the app directory, with a typical request starting with a route, which calls a controller, which interacts with a model, and finally renders a view—this is the MVC cycle in action.
-
-Don’t worry if you’re new to MVC! Just remember: every request starts with a route and ends with a response.
-
-## Building your first app
-
-As a maker, the easiest way to get started with your app is by building a Coming Soon, Early Access, or Pre-Launch page. This gives you something real to share while you build, helping you gather interest and early users. Let’s create a simple pre-launch page in Leaf MVC!
-
-### The routing bit
-
-In Leaf MVC, routes are defined in the `app/routes` directory. You’ll see an index.php file along with some files that start with `_`. These are partials, and Leaf automatically loads them to help you organize routes in a way that fits your project.
-
-For our pre-launch page, let’s create a new route inside `app/routes/_prelaunch.php`. Open the file and add this:
+This creates `app/controllers/UsersController.php`. Its `index()` method can return a JSON response:
```php
view('/prelaunch', 'prelaunch');
-```
-
-Okay, looks like some magic is happening here. Let's break it down:
-
-- `app()` is a helper function that gives you access to the Leaf app instance, it is available from anywhere in your app.
-- `view()` is a method that you can use to create a route that renders a Blade view. The first argument is what the user enters in the URL, and the second argument is the name of the view file to render.
-
-### The view bit
-
-We’ve set up the route, but if we navigate to `/prelaunch` now, we’ll hit an error—because we haven’t created the prelaunch view yet!
-
-To fix this, let’s create a new Blade file inside the `app/views` directory. Name it `prelaunch.blade.php`, and this is where we’ll define our pre-launch page.
-
-```blade [app/views/prelaunch.blade.php]
-
-
-
-
-
- Coming Soon
-
-
-
-
Something amazing is coming soon!
-
Sign up to be the first to know when we launch.
-
-
-
-```
-
-The most important part of this page is the form. We’re keeping it simple—it just collects an email address and submits it to a route that will handle the email. We’ll create that route in a moment. Remember how we set up a route earlier? We’ll follow the same approach!
-
-### Handling the form submission
-
-This is the final piece of our pre-launch page, but also the most involved. We need to create a route that calls a controller, validates the email, and saves it to a database using a model. This will take us through the full MVC cycle, plus a bit of setup.
-
-Let’s start by defining the route!
-
-```php [app/routes/_prelaunch.php]
-view('/prelaunch', 'prelaunch');
-app()->post('/store', 'SubscribersController@store'); // [!code ++]
-```
-
-We used the `post()` method because we only want POST requests to hit this route. The second argument specifies the controller and method that will handle the request.
-
-To generate the controller, we can use the Leaf CLI:
-
-```bash:no-line-numbers
-leaf g:controller subscribers
-```
-
-This will create a new controller in the `app/controllers` directory. Open the `SubscribersController.php` file and add the `store` method.
-
-```php [app/controllers/SubscribersController.php]
-json([
+ 'message' => 'Hello from Leaf MVC'
+ ]);
+ }
}
```
-We're almost there! We need to validate the email and save it to a database. Validation is pretty simple with Leaf, we can use the `validate()` method on our request and pass in the rules we want to validate against.
+Add a route in `app/routes/index.php` to call that method:
-```php{9-12} [app/controllers/SubscribersController.php]
-get('/users', 'UsersController@index');
+```
-namespace App\Controllers;
+Visit `http://localhost:5500/users` to see the response. Leaf matches the URL, calls `UsersController::index()`, and sends the JSON back to the browser.
-class SubscribersController extends Controller
-{
- public function store()
- {
- if (!$data = request()->validate(['email' => 'email'])) { // [!code ++]
- // validation failed, redirect back with errors // [!code ++]
- return response()->with('errors', request()->errors())->redirect('/prelaunch'); // [!code ++]
- } // [!code ++]
-
- // save the email
- }
-}
-```
+## Where your code goes
-Great job so far! Now, let's save the email to a database using a model. First, we'll generate a Subscriber model:
+The main application folders are:
-```bash:no-line-numbers
-leaf g:model subscriber
+```text
+app/
+├── controllers/
+├── database/
+├── models/
+├── routes/
+└── views/
+public/
```
-We don’t need to modify the model—Leaf keeps things simple. But before we can store anything, we need to connect our database. Open your .env file and add your database credentials:
+Routes map URLs to controller methods. Controllers handle the request, use models to work with data, and return a response. For HTML pages, templates live in `app/views/`.
-```env
-DB_CONNECTION=mysql
-DB_HOST=xxx
-DB_PORT=xxx
-DB_DATABASE=xxx
-DB_USERNAME=xxx
-DB_PASSWORD=xxx
-```
+
+
+ 01 / Model
+ Work with your data
+ Put model classes in app/models and database migrations in app/database.
+
+
+ 02 / View
+ Render a page
+ Keep page templates in app/views and serve assets from public.
+
+
+ 03 / Controller
+ Handle the request
+ Group related actions in app/controllers and connect them to routes.
+
+
-After updating your credentials, restart your server with: `leaf serve`.
+As your route list grows, you can split it into files such as `_auth.php` or `_api.php` in `app/routes/`. See [MVC routing](/docs/routing/mvc) for route partials and middleware.
-Now, we need to create our database table. Leaf MVC makes this seamless with schema files, a simpler way to define and manage your database structure. Let’s set up our schema next!
+## Add the features you need
-```bash:no-line-numbers
-leaf g:schema subscribers
-```
+Use [models](/docs/database/models) for database records, [authentication](/docs/auth/) for user accounts, and [billing](/docs/utils/billing) for payments and subscriptions. Each guide covers the setup and configuration for that feature.
-The name of the schema file should be the same as your table name. This will create a new schema file in the `app/database` directory. Open the file and add the columns you want in your table.
+For the frontend, you can render templates on the server or use a JavaScript framework. The [frontend guide](/docs/frontend/) covers Blade, Inertia, and Vite integration.
-```php [app/database/subscribers.yml]
-columns:
- email: string
-```
+## Working with an AI assistant
-Here, we are telling Leaf to add a column where we can store the email. We can now run the migration to create the table.
+Leaf CLI creates `.leaf/CONTEXT.md` with context about your project. Ask your coding assistant to read it before making changes, and point it to the feature you want to work on.
-```bash:no-line-numbers
-leaf db:migrate
-```
+For example:
-### Saving the email
+> Read `.leaf/CONTEXT.md`, then add a users endpoint. Follow the existing route and controller conventions, and use the project's user model.
-We can now save the email to the database using our `Subscriber` model.
+If your assistant cannot access the project folder, run:
-```php [app/controllers/SubscribersController.php]
-validate(['email' => 'email'])) {
- // validation failed, redirect back with errors
- return response()->withFlash('errors', request()->errors())->redirect('/prelaunch');
- }
-
- $subscriber = new Subscriber; // [!code ++]
- $subscriber->email = $data['email']; // [!code ++]
- $subscriber->save(); // [!code ++]
-
- return response() // [!code ++]
- ->withFlash('success', 'Nice, we will send a mail when we launch!') // [!code ++]
- ->redirect('/prelaunch'); // [!code ++]
- }
-}
-```
+Configure your production environment and point your web server at the `public/` directory. The [deployment guide](/learn/deployment/) covers hosting options and server setup.
-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 MVC. 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?
- Working with MVC can be a bit challenging if you are just starting out because of the overly strict separation of concerns. If you are stuck at any point, feel free to ask for help in the
- Leaf Discord server, or consider building an MVP using the Basic Leaf setup. While it is not as structured as MVC, it is a great way to get started with Leaf.
-
-
-
-
+If you get stuck, ask in the [Leaf Discord server](https://discord.gg/Pkrm9NJPE3).
## 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*.
-
-
+```
+
+### 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())
+ ->withFlash('old', request()->body()) // optional: what was typed, so the form is not empty after the redirect
+ ->redirect('/form', 303); // 303 is important for Inertia
+}
+```
+
+Every Inertia page automatically carries an `errors` prop read from the `errors` flash bag (leafs/inertia 5.1+), shaped `{field: "message"}` the way Inertia clients expect, and cleared on the next visit. The view does not pass it by hand:
+
+```php
+response()->inertia('form', ['old' => flash()->display('old') ?? []]);
+```
+
+Frontend (React):
+```jsx
+export default function Form({ old }) {
+ const { data, setData, post, errors } = useForm({ email: old.email ?? '' });
+ // errors.email is populated from the shared errors prop; usePage().props.errors has the same object
+}
+```
+
+Leaf's validator can hold several messages per field; the prop keeps the first. To build the bag yourself (a service rejecting a save, not the validator), flash `['field' => 'message']`. A page that passes its own `errors` prop, or `Inertia::share('errors', ...)`, overrides the shared one.
+
+### Not-found pages with a real 404
+
+Inertia clients accept any status on a response that carries the `X-Inertia` header, so a missing record renders the not-found page and still answers 404 to crawlers, `curl` and monitoring:
+
+```php
+// app/routes/index.php
+app()->set404(fn () => response()->inertia('not-found', [], 404));
+
+// a controller
+if (!$post) {
+ return response()->inertia('not-found', [], 404);
+}
+```
+
+`response()->inertia($page, $props, $status)`, `inertia()` and `Inertia::render()` all take the status as the third argument (leafs/inertia 5.1+). Without it the shell is served with 200 whatever the route decided.
+
+### 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..2f555cdf
--- /dev/null
+++ b/src/public/llms.txt
@@ -0,0 +1,946 @@
+# 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.
+- Seeded rows are stamped with the seeding time unless a row sets `created_at`/`updated_at` itself (leafs/schema 5.1+): `created_at: '@tick.subtract(30, "day").format("YYYY-MM-DD HH:mm:ss")'` backdates a row, no parallel date column needed.
+
+**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
+ 'notice' => Inertia::always(fn () => flash()->display('notice')), // survives partial reload filters
+]);
+
+// Validation: flash the errors and redirect 303; every page then carries an `errors` prop
+// ({field: "message"}, cleared on the next visit) that useForm().errors reads automatically (inertia 5.1+)
+return response()->withFlash('errors', request()->errors())->redirect('/form', 303);
+
+// A real 404 for missing records: the third argument is the status (inertia 5.1+)
+app()->set404(fn () => response()->inertia('not-found', [], 404));
+return response()->inertia('not-found', [], 404);
+
+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.
+- **Queue / scheduled jobs** (`leaf install queue`): a job class with a `schedule()` method returning `$this->cron('*/15 * * * *')` or `$this->every('day')->at('8:00')` runs on that schedule while `leaf queue:work` is running. That is the framework's scheduler; a nightly sweep does not need a crontab or lazy-on-load expiry. Details in https://leafphp.dev/ai/references/advanced.md.
+
+---
+
+## 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.
+18. **Don't hand-roll an `errors` prop or a not-found "page" in Inertia apps.** Flash `errors` and redirect; the shared `errors` prop is automatic. Pass the status as the third argument (`response()->inertia('not-found', [], 404)`) instead of serving a 200 that looks like a 404.
+19. **Don't add `decided_at`-style columns just to backdate seed rows.** A seed row can set `created_at` itself.
+20. **Don't run a project with a PHP older than the one that installed `vendor/`.** `composer install` resolves against the running PHP; a lock made on 8.4+ refuses an 8.2 CLI. Use the PHP that created the project (in the Leaf desktop app that is `~/.leaf/runtime/php`, on PATH through `~/.leaf/bin`).
+
+---
+
+## 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..78565e95 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,43 +30,49 @@ 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:
- Deliver long-awaited features like real-time APIs, better deployments, more 3rd party integrations, and auto API docs.
-- Build a sustainable ecosystem around Leaf — with docs, tutorials, and community tools.
+- Build a sustainable ecosystem around Leaf, with docs and tutorials, plus community tools.
- Keep everything Leaf free and independent, so anyone can build with it.
But to make this possible, we need you.
-Your support means Leaf’s maintainers can dedicate focused time to building, fixing, and improving. It means we don’t burn out. And it means Leaf stays healthy for the long run.
+Your support means Leaf’s maintainers can dedicate focused time to building Leaf, fixing what breaks, and improving what works. It means we don’t burn out. And it means Leaf stays healthy for the long run.

-— With love and gratitude,
+With love and gratitude,
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.
+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, actively 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
@@ -73,11 +80,11 @@ You can join our [Premium Sponsorship 🍁](https://opencollective.com/leaf/cont
- Mention in every release announcement
- Direct priority support from the Leaf team.
-Sponsoring Leaf isn’t just giving back — it’s investing in the PHP ecosystem.
+Sponsoring Leaf is both a thank-you and an investment in the PHP ecosystem.
## 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 🧡
@@ -91,7 +98,7 @@ We are committed to transparency and accountability in our financial management.
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/src/tutorial/src/step-9/description.md b/src/tutorial/src/step-9/description.md
index f3be0757..65a8b143 100644
--- a/src/tutorial/src/step-9/description.md
+++ b/src/tutorial/src/step-9/description.md
@@ -45,10 +45,10 @@ Your first task is to make a database connection using the `connect()` method. W
::: tip Test DB Credentials
-- Hostname: eu-cdbr-west-03.cleardb.net,
-- Dbname: heroku_fb1311a639bb407,
-- Username: b9607a8a6d5ebb,
-- Password: cc589b17
+- **Hostname**: eu-cdbr-west-03.cleardb.net,
+- **Dbname**: heroku_fb1311a639bb407,
+- **Username**: b9607a8a6d5ebb,
+- **Password**: cc589b17
:::
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',
},
},
},