Write guides and the changelog
The files behind the help centre and the feature pages — where they go, what they must contain, and how translations stay honest.
The help centre at /help, the feature pages at /features and the legal pages are all files in your repository: Markdown for the guides and the changelog, Blade for the features. Nothing is stored in the database and there is no editor. This guide gives you the exact formats, the routes that render them, the commands that keep the translations in step with the English, and what makes a guide worth reading.
Where those folders live and why they are named after your project is explained in /help/make-it-your-own. Everything below assumes config/app.php → project is set, your-project in the examples.
Where the files live
docs/your-project/help/
├── guides/
│ ├── get-it-running.md # English source
│ └── es/
│ └── get-it-running.md # Spanish, same file name
└── changelog/
├── 2026-09.md # one file per month
└── es/
└── 2026-09.md
App\Classes\HelpDocuments reads them from disk and caches what it parsed; the cache refreshes itself whenever a help file changes, so saving the file and reloading the page still shows the change, with no command to run. Only the top level of each folder is read; the locale folders hold translations and never enter the listing on their own. There is one locale folder per language in config/app.php → languages other than en, which is es out of the box.
The generic guides
docs/project/help/ holds a help centre written for the user of any product: guides on editing a profile, what the profile's country changes, plans, add-ons and usage. A project with no docs/your-project/help/ folder reads this one instead, its guides and its changelog, with nothing to copy or configure. A project with its own help folder reads only its own, whatever its name, so Weblabor Base, Weblabor Teams and Weblabor Builder never show these. The folder follows the same layout, with es/ for the translations, and a change to it reaches the help centre the same way, with no cache to clear.
Once you create your own help folder, the generic guides stop showing. Copy into docs/your-project/help/guides/ the ones you want to keep, with their translations, and edit them as yours. A generic guide whose category your config/help.php does not declare behaves like one of yours would: its URL opens, and the index leaves it out.
A guide
The file name is the slug: get-it-running.md is /help/en/get-it-running. Every help page carries its language in its address, and each translation declares its own slug, as Translations explains. A guide opens with a front matter block and continues in Markdown:
---
title: Get it running
summary: From a clone to a signed-in admin on your machine in one sitting.
category: getting-started
order: 10
---
The first sentence says what the guide covers and when you need it.
## First section
| Key | Required | What it does |
|---|---|---|
title |
yes | The heading of the page and the card. Without it the slug is shown. |
summary |
yes | One sentence under the title and on the card. The search box matches it. |
category |
yes | One of the keys of config/help.php. |
order |
yes | Position inside the category: 10, 20, 30. Ties fall back to the title. |
requires |
no | Shows the guide only when something is switched on: plans, add-ons, tickets, support-chat, or several separated by a comma. |
A file without a front matter block is ignored entirely. HTML comments in the body are stripped before rendering, except inside a code block, which is how the fingerprint comment stays invisible.
requires: plans is for a guide about subscriptions, which would only confuse the reader of a project that sells none. The guide is shown only while plans are active: FEATURE_PLANS_ENABLED on, a Stripe secret key configured, and at least one plan created, as /help/turn-on-plans-and-billing explains. Until then it is missing from the guides index, from its category page, from the search and from the sitemap, and its URL answers like an unknown slug. A category left with no guide to show disappears with it. The check runs on every request, so turning plans on or creating the first plan shows the guide on the next page load, with no cache to clear.
requires: add-ons is for a guide about add-ons. It is shown only while FEATURE_ADDONS_ENABLED is on, the switch that opens the Add-ons page, and is checked on every request the same way. A guide that needs both writes requires: plans, add-ons and is shown only while both are met. requires: tickets is for a guide about support tickets, shown only while FEATURE_TICKETS_ENABLED is on. requires: support-chat is for a guide about the live support chat, shown only while the chat is on: tickets, FEATURE_TICKETS_CHAT_ENABLED and real time. Any other value hides the guide for good, so check the spelling. The requirement is read from the English file only: a translation that declares one changes nothing.
Categories
config/help.php lists the categories a guide can declare. Each entry is a key with its English label, or a key with an array that also names the icon and the description its card shows:
'billing' => 'Plans and billing',
'admin' => [
'name' => 'Admin panel',
'icon' => 'squares-2x2',
'description' => 'What ships under /admin and how to extend it.',
],
The views pass the name and the description through __(). A project that still uses the short form keeps working. A category card shows the icon and the description when they are declared, and the number of guides always. The kit ships twelve:
| Key | Label |
|---|---|
getting-started |
Getting started |
authentication |
Sign-in and accounts |
configuration |
Configuration |
billing |
Plans and billing |
admin |
Admin panel |
notifications |
Notifications |
customisation |
Making it your own |
deployment |
Deployment |
security |
Security |
development |
Working on it |
support |
Technical support |
updates |
What changed |
A category with no guide is left out of the guides page, so trimming the list to what your reader needs never shows an empty heading. A guide whose category is not in the list is not shown on the guides page, though its URL still opens. config/ is yours, so the list survives every merge from the base.
help.guides_description is the sentence under the guides, shown as the description of the Guides card at /help/{locale} and under the heading at /help/{locale}/guides. It ships as "Answers and step-by-step instructions for using it."; change it to what your own guides are about. It goes through __() and lang:search picks it up, so lang:update translates it like any other string.
The subtitle and the category descriptions ship neutral, written for whoever uses your product. The kit's own help centre speaks to whoever installs it, so its texts live apart, under help.kit, and only the kits read them. Your project ignores that block: to write your own texts, edit guides_description and the description of each category.
help.category_threshold, 20 by default, decides the shape of /help/{locale}/guides: with more guides than that, the page shows only the category cards, each opening /help/{locale}/guides/{category}; with that many or fewer, it shows every category with its guides.
What renders
The body goes through help_markdown() in app/Helpers/base.php, which converts Markdown and puts the help centre's Tailwind classes on each element. Paragraphs, ## and ### headings, bulleted and numbered lists, links, bold and italic, inline code, fenced code blocks, block quotes, tables, images and horizontal rules all come through styled. Do not write a # heading: the title comes from the front matter. Link other guides by their public path without a language, with the English file name, [Deploy your project](/help/deploy-your-project), and never by a file path: the reader does not have the repository open. The link opens the other guide in the language of the text being read, under that language's slug, so the Spanish copy keeps the same path.

Link to a section
Every ## and ### heading in a guide can be linked to. Its anchor is the heading text in lowercase, spaces turned into dashes and punctuation dropped; accents stay. ## For developers is #for-developers, ## Para desarrolladores is #para-desarrolladores, and ## Plans & prices is #plans--prices. When the same heading appears twice in one guide, the second is -1, the third -2. Nothing is added to the heading on screen.
Inside a guide, link a section of the same guide with the anchor alone, and link a section of another guide by its path and the anchor:
See [For developers](#for-developers) below.
See [Declare a meter](/help/plan-limits-and-usage#declare-a-meter).
The anchor comes from the heading in the guide's own language, so the Spanish copy uses its Spanish heading: [Para desarrolladores](#para-desarrolladores). Changelog entries have no anchors.
The heading changes with the language, so a link from Blade — a feature page's guide, for example — cannot carry a fixed anchor. Build it with help_anchor() from the translated heading:
<x-design::landing-block
:title="__('Let your users browse and buy add-ons themselves')"
:guide="'/help/sell-add-ons#' . help_anchor(__('When a change would leave the account over its quota'))"
:guide-label="__('What happens when an add-on is cancelled')">
{{ __('Free ones switch on with a click; paid ones join the subscription.') }}
</x-design::landing-block>
The lang/ value must be the translated guide's heading word for word. When it is not, the link still works but opens the guide at the top. help_anchor() does not number repeated headings, so it always reaches the first one.
Offer the way back
A link to a guide from another page of your site can add back, the address to return to, and back_label, the name of that page. The guide then shows Back to {back_label} above Back to the help centre, leading to back:
<a href="{{ help_url('/help/sell-add-ons') . '?' . http_build_query(['back' => request()->getRequestUri(), 'back_label' => __('Add-ons')]) }}">
{{ __('How add-ons work') }}
</a>
Only an address of your own site is accepted: a path that starts with a single /, or an http or https URL on the domain the visitor is using. Anything else — another domain, an address starting with //, a javascript: link — is ignored. The link appears only when both values arrive, the address is accepted and the name is not empty; otherwise the guide looks as it always does. The name is shown as plain text.
Tables
A table is rendered inside a container that scrolls on its own. A table wider than the screen is dragged sideways with a finger or a trackpad, and the rest of the page stays still; a table that already fits looks exactly as it always did. Nothing on screen says a table can be dragged: the gesture is the same one every wide table on a phone answers to, and no arrow, shadow or notice is added.
Inside a cell, a value between backticks is never split across two lines. POST /api/stripe/webhook stays one value instead of breaking after the slash, and webhook-events instead of breaking after the hyphen — that is the value the reader copies, so the table scrolls rather than the value breaking. Outside a table, in a paragraph or a list, inline code still wraps as it always has: a long fragment there breaks over two lines instead of pushing the paragraph past the edge of the page.
So write the table the guide needs, and put the routes, file paths and config keys in its cells between backticks. On a phone a wide table costs the reader a swipe; a value that breaks in half costs them the value.
Images
A screenshot lives under public/images/help/{project}/{guide-slug}/, named in lowercase kebab case with the .png extension, and is written with the Markdown syntax, the alt text in the guide's language:

That is the only syntax that works: a raw <img> tag is escaped and shows as text. A path that points at no file renders nothing, not a broken image, so a typo in the path hides the picture instead of breaking the page; open the guide after adding one. Changelog entries carry no images.
The changelog
One file per month, named YYYY-MM.md. Its front matter is a single month key — the file is ignored without a front matter block — and each entry is a ## heading with the date and time in UTC. Inside an entry the bullets are grouped by theme, one ### heading each:
---
month: 2026-09
---
## 2026-09-03 01:33
### Help centre and guides
- The help centre is open. You can search the guides and read them in English
or in Spanish. [Registration and access](/features/registration-and-access)
- Several fixes were made to the help centre.
### Subscriptions
- Changing plan hands you the new plan's quota straight away, instead of leaving
you on the quota of the plan you just left until the cycle turned.
[Plan limits and usage](/help/plan-limits-and-usage)
### Corrections
- Several fixes were made to payments.
- Several fixes were made elsewhere in the product.
## 2026-09-01 15:10
### Notifications
- Web push notifications reach your phone with the site closed.
[Notifications](/features/notifications)
The stamp is the moment that entry's changes were published to your users, not the moment they were written: several work sessions that ship together are one entry.
Every bullet lives under a theme heading, always: there is no number of bullets below which a flat list is allowed. The heading is ### and never ## — a ## inside an entry is read as another entry, and dropped for having no date. A bullet that fits no theme of its own goes under a generic ### Other changes, which is a theme like any other — including the rule below about a theme left with nothing but fixes.
Inside a theme, each bullet is one of three kinds:
- Something new. A short line saying what shipped and what it is for, with a link to its guide or its feature page.
- A fix or an adjustment that changes nothing the reader was relying on. All of them in a theme become one generic line — "Several fixes were made to subscriptions." — however many there were and however unlike each other. No breakdown, no technical detail, no link.
- A change to something that already existed that could affect what the reader was using. One summary line in the reader's terms, with a link. Link a dedicated update guide in the
updatescategory, saying what it did before, what it does now and why it changed, when the change is important enough to deserve one; otherwise link the feature's own guide, corrected to describe how the thing behaves now.
A theme whose only line in an entry is that generic fix line does not keep a heading of its own. Those lines gather in one shared section at the end of the entry, ### Corrections, one line per theme, and Other changes folds in there like any other theme, its line reading "Several fixes were made elsewhere in the product." A theme with something new, or with a change that affects the reader, keeps its own heading, and its fix line stays inside it, last.
Which theme a bullet belongs to, whether a change could affect the reader, and whether it earns an update guide of its own are yours to decide as you write. Nothing is inferred and there is no field to fill in.
Bullets describing the same change become one line. Bullets on genuinely different subjects stay apart even under the same heading; only the minor fixes of a theme always merge.
/help/{locale}/changelog shows the newest month first, ordered by file name, and inside it the newest entry first. Entries are grouped by day and the time is printed as HH:mm, both after converting the UTC stamp to the reader's timezone: the timezone of the signed-in account, or config/app.php → timezone for a visitor. An entry written at 23:30 UTC lands on the next day for a reader east of Greenwich, on purpose. A heading that does not start with a date is skipped, and a month file with no valid entry is skipped too. The page loads one month and offers Show earlier months while there are more files.
Translate a month the same way as a guide: changelog/es/2026-09.md, same front matter, the entries translated, the stamps untouched.
Translations
The translated file has the same name inside the locale folder. Translate title, summary and the body: those three are all the help centre takes from the translation. category, order and requires always come from the English document, so whatever the translation says for them changes nothing. A translation that leaves out title shows the file name instead, and one that leaves out summary shows none, so write both.
A translated guide also declares slug, the name of its address in that language, written with the words of its title, without accents, in lowercase and joined by hyphens: the Spanish copy of what-weblabor-base-is.md declares slug: que-es-weblabor-base and opens at /help/es/que-es-weblabor-base. Two guides cannot share a slug in the same language. A translation that declares none keeps the English file name in its address. Changing a slug changes a published address, so keep it once the guide is out.
A guide exists only in the languages it is written in. Without a translation for a language it is missing from that language's lists, categories and sitemap, and its address in that language answers 404; the same goes for a month of the changelog. Addresses published before the help centre carried a language, such as /help/get-it-running, redirect to the same page in the reader's language.
Each translation carries a fingerprint of the English it was written from, as an HTML comment under the front matter:
<!-- weblabor:doc source="ac37ca28c35b" translated="347cff9caa63" -->
source hashes the English title, summary and body; translated hashes the translation's own. Two commands maintain it:
php artisan help:stamp # rewrites the comment on every translation
php artisan help:stamp --check # reports problems and exits with an error
Run help:stamp after writing or updating a translation. Never write the comment by hand. --check reports:
| Problem | Meaning |
|---|---|
missing translation |
An English document has no file in a locale folder. |
out of date |
The English changed after the translation was stamped. Update the translation, then stamp. |
no marker |
The translation has no fingerprint comment: it was never stamped. |
orphan |
A translation whose English document no longer exists. Delete it or restore the English. |
no front matter |
A document without a front matter block, which the help centre ignores. |
no slug |
A translated guide of the kit that does not declare its slug. |
repeated slug |
Two guides shown together use the same slug in the same language. |
When a translation was corrected by hand after stamping, help:stamp restamps it and warns that it was edited by hand; the correction is kept.
composer install runs git config core.hooksPath scripts/git-hooks, and the pre-push hook there runs php artisan help:stamp --check --pushed, so a push that would ship a missing or stale translation is stopped on your machine. It checks only the help documents the push changes, with their pair, as they stand in the commits it sends: a document already broken on the branch does not stop it, and what is not in a commit does not count. Run --check alone to check the whole working folder. Skip the hook once with git push --no-verify.
Annexes and other folders
help:stamp checks the folders listed in translated_folders in config/help.php: guides and changelog by default. A project that keeps other help text, such as annexes shown inside a guide depending on the project, adds its folder to that list:
'translated_folders' => ['guides', 'changelog', 'annexes'],
A file in an added folder follows the same rules as a guide: it lives in docs/{project}/help/annexes/, it needs a front matter with at least a title, its translation goes in the locale folder beside it, and --check reports the same problems for it. A folder in the list that does not exist yet is skipped.
To show one of those texts inside a guide, read it with HelpDocuments::text():
use App\Classes\HelpDocuments;
HelpDocuments::text('annexes', 'hotel-rooms');
It returns only the body, without the front matter or the fingerprint comment: in the reader's language when a translation exists, in English otherwise. It looks in the project's own help folder, or in docs/project/help when the project has none, like the guides, and returns null when the file does not exist or the folder is not in the list.
The routes
| URL | What it shows |
|---|---|
/help/{locale} |
The two doors: Guides and What's new. |
/help/{locale}/guides |
With more guides than help.category_threshold, one card per category with its icon, description and number of guides; with that many or fewer, every category that has guides, with its guides. Either way, a search box filters every guide by title and summary as you type. |
/help/{locale}/guides/{category} |
The guides of one category, whatever the threshold. An unknown category, or one whose every guide is hidden by its requires, is a 404. Its search box filters that category's guides only, and says nothing matched when none of them does, even if another category has a match. |
/help/{locale}/changelog |
The changelog described above. |
/help/{locale}/{slug} |
One guide, under its slug in that language. An unknown slug, a guide whose requires is not met or one with no translation in that language is a 404. |
/sitemap.xml |
Every page above in every language it exists in, with its other languages. /robots.txt points to it. |
{locale} is each language in config('app.languages'), and the fixed words follow it: in Spanish they are /help/es/guias and /help/es/cambios, set in help.segments in config/help.php. The language of the address wins over the reader's profile and is never saved, and each page tells search engines its other languages. The old addresses without a language, /help, /help/guides, /help/guides/{category}, /help/changelog and /help/{slug}, redirect permanently to the reader's language. Build a link with help_url('/help/sell-add-ons'): it returns the address in the reader's language.
All of them are public: they sit outside the guest group, so a signed-in user reads them too. The slug is matched against the documents that were found, never turned into a path, so ../ in a URL reaches nothing. The pages use resources/views/layouts/help.blade.php, a header with the help centre link, a link to the website, a link to the user panel for a signed-in reader, and the language switcher, which leads every reader to the same page in the other language.
Feature pages
/features is the sales side: what your product does, one page per feature, written in Blade rather than Markdown. The list is resources/views/landing/your-project/features.php:
return [
[
'slug' => 'your-account',
'title' => 'Your account',
'summary' => 'Keep your details, your alerts and your preferences the way you want them.',
'icon' => 'user-circle',
'order' => 20,
],
];
| Key | Required | What it does |
|---|---|---|
slug |
yes | The URL segment, and the name of the view. |
title |
no (defaults to the slug) | The heading of the card; passed through __(). |
summary |
no | One sentence on the card; passed through __(). |
icon |
no (default sparkles) |
A Heroicons name for the card. |
image |
no | A screenshot under public/ for the card, where a home page lists the features with pictures, as the base's own home does through $this->features. A path that does not exist is ignored. |
order |
no (default 0) |
Position in the listing; ties fall back to the title. |
Each entry needs a view at resources/views/landing/your-project/features/{slug}.blade.php. An entry whose view does not exist is left out, so you can declare the list first and write the pages one by one. /features lists them (App\Livewire\Web\Features) and /features/{section} renders one inside layouts/web.blade.php; an unknown section is a 404.
Build a page from three components:
<div>
<x-design::landing-hero
:title="__('Your account, the way you want it')"
:promise="__('Your details and your preferences stay where you put them.')" />
<section class="max-w-6xl mx-auto px-6 pb-8 space-y-24">
<x-design::landing-block
:title="__('Put a face to your account')"
image="images/landing/your-project/features/your-account/profile.png"
guide="/help/your-account-area"
:guide-label="__('How to change your picture')">
{{ __('Upload a picture from your profile and it appears everywhere your account does.') }}
</x-design::landing-block>
<x-design::landing-block :title="__('Keep the number where your codes arrive')" :reverse="true">
{{ __('Change your phone whenever you switch lines.') }}
</x-design::landing-block>
</section>
<x-design::landing-cta :title="__('Create your account and see for yourself')" />
</div>
| Component | Attributes | What it renders |
|---|---|---|
<x-design::landing-hero> |
title, promise; optional size |
The page heading and the one-line promise under it. size="lg" is the bigger version the home page uses. |
<x-design::landing-block> |
title; optional image, guide, guide-label, reverse |
A heading, the slot as its paragraph, and a screenshot beside it. image is a path under public/; when the file does not exist the text takes the full width, so a page ships before its screenshots. guide adds a link with guide-label as its text. reverse puts the image on the right. |
<x-design::landing-cta> |
title; optional size, and in Weblabor Base's design href and action |
The closing panel with a Create your account button to the registration page. size="lg" is the bigger version the home page uses. In Weblabor Base's design, href sends the button elsewhere with action as its label, in a new tab when the address is outside the site. |
Two more files in the same folder belong to the landing. home.blade.php is the home page at /, required once the folder exists: without it the home page answers with an error naming the file to create, and a project with no landing folder at all shows the generic home in resources/views/landing/project/. nav-links.blade.php is optional and prints the product links of the public menu after the fixed Features and Help entries; the public footer also links Privacy and Terms, served at /privacy and /terms, public, and the registration form's I agree to the Terms and Conditions checkbox opens the second one. Their text is yours to write, in lang/{code}/legal/{project}.php: one file per language, named after project in config/app.php, so the base's own legal texts never collide with yours on a merge. Until your file exists the pages show the generic texts in lang/{code}/legal/project.php, so the links always open something.
Each file returns a terms and a privacy document. A document has a title, an updated_on date you change whenever you rewrite the text, and its sections: each one an optional heading and a body, in which a string is a paragraph and a list of strings is a bulleted list. :app is replaced by the app name and :account_url by the address of the profile page, where a user deletes their account. Copy legal/project.php to start, and write every language yourself: lang:update does not translate these files.
Write the feature pages as sales pages, not documentation: lead with what the reader gets, one or two sentences each, second person, present tense. Every string goes through __() so it can be translated in lang/. See /help/languages-and-translations.
How to write a good guide
The reader is a developer in a hurry who bought your product and is installing, configuring or deploying it, reading on the public site without the repository open.
- Open with one or two sentences saying what the guide covers and when it is needed. Then sections. Stop when the content stops: no conclusion, no further reading.
- Second person, present tense, short sentences. "Set
APP_URLto your domain", not "the developer should set the URL". - Name what the reader will type: the file path, the config key, the environment variable, the class, the command. Put commands in fenced blocks with a language.
- Never send the reader to another document of the repository. If the fact matters, write the fact. Link another guide by its public path when it helps.
- Document only what the code does. An option that exists but nothing reads is written as such. Use made-up data in examples:
[email protected],your-project.test. - Write the English first, then the translation, and translate all of it: a translation with English paragraphs left inside is worse than the notice the help centre shows for a missing one. Run
php artisan help:stampwhen both are done. - Every change a user can see gets its changelog entry the same day it ships, in the language of the person who will read it, with the time in UTC.