LaRecipe has no users or roles of its own. It asks your app, through Laravel's auth and gates, at three levels:
| To make private | Write |
|---|---|
| A whole doc set | access.mode auth or gate in its entry in config/larecipe.php |
| A version or a page | visibility: private, on the version in larecipe.json or in the page's frontmatter |
| One paragraph | <Can ability="..."> around it |
Private versions and pages ask your viewLarecipe gate. Define it in a service provider, like any other gate:
Gate::define('viewLarecipe', fn (?User $user) => $user !== null);One policy answers for every surface: a page or a paragraph a reader may not see is missing from the HTML, the Markdown twin, search, llms.txt and MCP alike.
Who may read the set
access.mode in config/larecipe.php sets the door for a whole doc set:
'sets' => [
'handbook' => [
'route' => ['prefix' => 'handbook', 'middleware' => ['web', 'auth']],
'source' => ['driver' => 'repository', 'path' => base_path('handbook')],
'access' => ['mode' => 'gate'],
],
],| Mode | Who may read | Everyone else gets |
|---|---|---|
public |
Everyone, guests included. The default. | Nobody is turned away |
auth |
Anyone signed in with your default guard. | Your app's unauthenticated response, usually a redirect to the login page |
gate |
Readers your viewLarecipe gate allows, asked once for the set and again for every page. |
A 404 |
In gate mode a guest gets a 404 like anyone else the gate refuses. To send guests to your login page first, add auth to the set's route.middleware, as above.
The gates
LaRecipe asks three gates:
| Gate | Arguments after the user | Asked |
|---|---|---|
viewLarecipe |
?PageSummary $page, string $set |
In gate mode, for the set (with no page) and for every page. In any mode, for private versions (with no page) and private pages. |
manageLarecipe |
string $set |
Before the admin UI opens, before the web MCP endpoint offers the validate_docs tool, and before it accepts remote author tools on a database set. |
askLarecipe |
string $set |
Before showing the Ask AI panel and before answering a question, when Ask AI is enabled. |
use App\Models\User;
use Illuminate\Support\Facades\Gate;
use LaRecipe\Domains\Content\DTOs\PageSummary;
public function boot(): void
{
Gate::define('viewLarecipe', function (?User $user, ?PageSummary $page, string $set) {
if ($set === 'handbook') {
return $user?->is_staff ?? false;
}
// Private pages in the public docs: any signed-in customer.
return $user !== null;
});
Gate::define('manageLarecipe', fn (User $user, string $set) => $user->is_admin);
Gate::define('askLarecipe', fn (?User $user, string $set) => $user !== null);
}$page is a PageSummary, not the page body: read $page->ref->path, $page->ref->version, $page->ref->locale, $page->title and $page->private. It is null when LaRecipe asks about the set or a private version, so type it as nullable.
When a gate is not defined
| Gate | Without a definition |
|---|---|
viewLarecipe |
Any signed-in reader passes in the local environment. Everyone else, everywhere else, is refused. |
manageLarecipe |
The same as viewLarecipe. |
askLarecipe |
Everyone may ask, guests included, as long as Ask AI is enabled. |
So a private page works out of the box on your machine for anyone signed in, and stays closed in production until you define viewLarecipe.
Private versions and pages
A page is private when its frontmatter says so, and a version when its entry in larecipe.json does:
---
title: Roadmap
description: What ships next quarter, for customers on the Enterprise plan.
visibility: private
---{
"versions": [
{ "id": "4.x", "status": "current", "visibility": "private" },
{ "id": "3.x", "default": true }
]
}Both need viewLarecipe, in every access mode. A reader the gate refuses never learns either exists: they are left out of navigation, the version and language switchers, search, llms.txt, the sitemap and MCP, and a direct request answers 404.
Why a hidden page is a 404, never a 403
LaRecipe answers a page the reader may not open exactly as it answers a page that does not exist: a real 404, in HTML and in Markdown, with suggestions drawn only from pages the reader can see. A 403 would confirm that the page is there. The one exception is the door itself: in auth mode, a guest gets your app's login response for the whole set.
For a private version the gate is asked with no page; for a private page it receives the page. A visibility value is trimmed and lowercased, and anything other than public counts as private, so a typo keeps a page closed.
Gate blocks: Can and Cannot
Sometimes most of a page is for everyone and one paragraph is not. Wrap that part in <Can> or <Cannot> with the name of a gate your app defines:
<Can ability="view-billing">
Enterprise plans can export invoices as CSV from **Billing**, then **Exports**.
</Can>
<Cannot ability="view-billing">
Ask your workspace owner for access to billing exports.
</Cannot>Gate::define('view-billing', fn (?User $user) => $user?->plan === 'enterprise');<Can> shows its content to readers the ability allows. <Cannot> shows its content to everyone else, guests included: the place for "sign in to see more" or "ask your admin". The content is ordinary Markdown. LaRecipe removes the blocks a reader may not see before anything renders, so there is nothing to find in the page source.
Rules for gate blocks
- The ability is checked with no other arguments, as
Gate::allows('view-billing')would. Policies that need a model do not fit: use a gate that decides from the user alone. - An ability you never defined denies everyone.
<Can>stays hidden and<Cannot>shows, so a typo hides content instead of leaking it.php artisan larecipe:validatewarns about it asaccess.unknown-ability, with the closest ability your app defines. - A block without
abilityis hidden from every reader, andlarecipe:validatereports the missing prop. - Write each tag on its own line, in PascalCase, with a blank line before and after. A gate tag the parser cannot read (inside a sentence, a table or a heading, split over several lines, or in lowercase such as
<can>) fails closed: the block around it, up to its closing tag, is hidden from every reader, andlarecipe:validatereports anaccess.gate-syntaxerror. - A gate block on a private page is reported as
access.redundant-gate: its readers already passedviewLarecipe, so check whether the block is still needed. - Removed, not hidden. Nothing is hidden with CSS. Headings inside a removed block leave the table of contents too, and a quote that only held a removed block disappears with it.
- Cached per audience. Readers who hold the same abilities on a page share one cached render, and a reader who holds none of them gets the guest render.
Every surface, the same rules
Private pages and gate blocks follow the reader into search, the Markdown twin, llms.txt and MCP. The surfaces that hand an agent whole pages (llms-full.txt, the MCP read_page tool, and Ask AI, which reads through it) serve every caller the guest view of gate blocks, so they can show an agent less than the HTML shows its reader, never more.
Surface by surface
| Surface | Private sets, versions and pages | Gate blocks |
|---|---|---|
| HTML page | Shown only to readers who may see them | Removed for readers who may not see them |
Markdown twin (.md) |
The same | The same view as the HTML, for the same reader |
| Search | Each reader's index holds only pages they may see | Records are split at gate blocks and kept only for readers they allow, in the browser index and the server ranker alike |
llms.txt, llms-full.txt |
Built for the caller, so they list only what the caller may see | llms-full.txt holds the guest view |
skill.md, sitemap.xml |
skill.md is built for the caller; the sitemap lists only what a guest may see |
Neither holds page content |
| MCP | search_docs, read_page and list_pages see only what the caller may see |
search_docs follows the caller's abilities; read_page returns the guest view |
| Ask AI | Reads through the same MCP tools, as the reader | The guest view, through read_page |
To let agents read a private set over MCP, put an authentication middleware in front of its endpoint with mcp.web.middleware, such as ['auth:sanctum']. Access is checked again on every tool call. See the MCP server.
Share one private page
To show a private page to someone without an account, such as a customer or a contractor, issue a share link:
php artisan larecipe:share 3.x/billing/roadmap --expires=7dIt prints the page URL with a share token. Anyone holding the link can read that one page until it expires, even when the page, its version or its whole set is private. Every other page stays closed, and gate blocks still follow the reader.
Share links in detail
- The page is written
set/locale/version/path, where the set, the language and the version are optional and recognized by name:docs/ar/3.x/billing/roadmap, orbilling/roadmapalone for the default set, language and version. - The lifetime is
30m,12h,7dor2w, and defaults to7d. - The link opens the page as HTML or Markdown. The sidebar lists only what the reader could see anyway, and a guest with the link gets the guest view of gate blocks.
- Revoking. Links are signed with your
APP_KEY, so rotating the key revokes every share link at once. There is no way to revoke a single link before it expires. - The command signs whatever path you give it, so open the link once to check it reaches the page you meant.
- A share link gets past LaRecipe's checks, not your route middleware. On a set whose
route.middlewareincludesauth, a guest is sent to log in before the link is read, so useaccess.modeauthorgatewithout that middleware for sets you share from.
Read live from this app:
larecipe:shareIssue a temporary signed link that lets anyone read one private page
php artisan larecipe:share [--expires [EXPIRES]] [--] <page>| Argument | Description | Default |
|---|---|---|
page | The page, as set/version/path (for example docs/3.x/billing/roadmap) (required) |
| Option | Description | Default |
|---|---|---|
--expires=EXPIRES | How long the link works: 30m, 12h, 7d, 2w | 7d |
Response headers
A response only one reader may see (a page in a set that is not public, a private page or version, a page opened with a share link, or a page whose gate blocks granted the reader something guests do not get) goes out Cache-Control: private, no-store with X-Robots-Tag: noindex, so no shared cache stores it and no search engine indexes it. A public page with gate blocks, served to a guest, stays cacheable but varies on Accept and Cookie. Security lists the headers for every case.
Next
- Configuration basics:
access.modeand the rest of a doc set's settings. - The MCP server: authenticating agents for private sets.
- Security: go deeper into what LaRecipe does to keep private docs private.