Build a website on Aurora CMS (shipaurora/cms): create and register page blocks, add the website's own settings tabs, write the page layouts the editor and the site share, and use Aurora's models and helpers without depending on what Aurora is built on.
php artisan boost:add-skill shipaurora/cms
Save this content to: .claude/skills/aurora-development/SKILL.md
# Aurora development
Aurora is the CMS; the app is the website. The website gives Aurora its blocks, its settings tabs and its page view through the hooks below, from its own service provider. Never import the packages Aurora builds on (the page builder, Filament plugins) in website code: use the `Aurora\` classes, so Aurora can change underneath without breaking the site.
## Blocks
A block is a class and a Blade view. Create both with `php artisan aurora:make-block "Pricing table"` (`--container` for a block that holds other blocks, `--category=`, `--icon=`), then fill them in.
1. The class extends `Aurora\Blocks\Block` (in `config('aurora.block_path')`, default `app/Blocks`): `type()` (a short slug, also the view name), `label()`, `description()`, `schema()` (Filament fields for the inspector), and optionally `icon()` (a Phosphor icon, e.g. `phosphor-chart-bar`), `category()` (layout, content, marketing, media, design or developer), `defaults()` (the data a new block starts with: make it look finished), `editables()` (text editable on the canvas: `Aurora\Blocks\Editable::text()`, `::richText()` for a `RichEditor` field, which gets bold, italic, underline, strikethrough, links, H2/H3, lists and quotes right on the page (anything more, such as a table, is edited in the inspector), `->multiline()`, `->placeholder()`; mark the element with `@editable('field')` and draw it with `@shows` so an empty field can still be clicked), `itemEditables()` (the same for the fields of a repeater's items, `['items' => ['question' => Editable::text()]]`; mark each with `@editableItem('items', $loop->index, 'question')`), `aiHints()` (what the AI should know when filling it in), `sample()` (the block as its screenshot shows it: `defaults()`, and for a container the blocks in it, `[...parent::sample(), 'children' => ['items' => [['type' => 'feature', 'data' => FeatureBlock::defaults()]]]]`) and `preview()` (the picture beside it in the visual editor's block list: by default the site's screenshot, below; a block package returns an image URL of its own).
2. The view is an anonymous component at `resources/views/components/blocks/{type}.blade.php` with `@props(['data' => []])`. The same view draws the block on the website, in the visual editor and in previews. Mark canvas-editable text with `@editable('field')`. Render fields with the global helpers, no `@php use` imports needed: `inline_text()` (with `*highlighted*` words), `plain_text()`, `rich_text()`, `shows_field('body', $data['body'] ?? null)` for optional fields, `link_href($data)` / `link_target($data)` for a `Fields::link()` group, and `is_editing()` to skip animations or show placeholders while editing (each is a short form of `Aurora\Support\Markup`, `Aurora\Blocks\Fields` or `Aurora\Blocks\Blocks`). Each also has a Blade directive (`@inlineText(…)`, `@plainText`, `@richText`, `@linkHref`, `@linkTarget`, `@blockSlot('name')`, and `@editing … @endediting`, `@shows('body', $data['body'] ?? null) … @endshows`). Render media library images with `<x-aurora::media :value="$data['image'] ?? null" size="md" />`.
3. A block that holds other blocks implements `Aurora\Blocks\Contracts\Container` (`slots()` and `defaults()`) and draws each slot with `{{ block_slot('name') }}`. A block view can ask where it is: `current_page()` (the `Aurora\Models\Page` being drawn, on the website, in previews and on the canvas; null in a palette preview), `block_depth()` (`@blockDepth`, 0 straight on the page, 1 in a column) and `is_nested_block()` (`@nested … @endnested`), from `Aurora\Blocks\Blocks::page()`, `::depth()` and `::isNested()`.
4. Blocks in `config('aurora.block_path')` are registered on their own. To set their order in the block palette, list them in a service provider's `boot()` (the rest follow):
```php
use Aurora\Blocks\Blocks;
Blocks::register([HeroBlock::class, StatBlock::class]);
```
Labels and descriptions go through `__()` and need a line in the app's `lang/{locale}.json`.
**Give every block a screenshot.** Editors pick blocks by their picture in the visual editor's block list, and a block without one shows only an icon. Each is a screenshot of the block alone, drawn from its example data (`sample()`, which is `defaults()` unless a container adds the blocks in it), committed with the site's code as `resources/block-previews/{type}.webp`; `Block::preview()` finds it on its own. Until a block has one, the block list shows the block itself, live from its sample page, which is heavier to load and needs the block's JavaScript, so a screenshot is still worth committing. So make `defaults()` look like real, finished content, and take the screenshot again when the block's look changes. When asked to **generate the missing block screenshots** (or after making a block):
1. `php artisan aurora:block-previews --missing --json` lists the blocks without one: each with its `sample_url` (the block alone with the site's stylesheet, at a signed address that works for two hours without signing in) and the `file` to write.
2. Screenshot each `sample_url` in a 1600×900 window with the browser you have (a browser tool, Playwright, or headless Chrome: `"<chrome>" --headless=new --hide-scrollbars --window-size=1600,900 --screenshot=/tmp/hero.png "<sample_url>"`). The site must be running at its `APP_URL`.
3. `php artisan aurora:block-previews hero --from=/tmp/hero.png` saves it as the block list wants it: 800×450 WebP in `resources/block-previews`. Look at it, and fix the block's `defaults()` if it looks empty or broken.
4. Commit the files with the blocks.
With `spatie/browsershot` installed (optional; it needs Chrome or Chromium and Node's `puppeteer`), `php artisan aurora:block-previews --missing --generate` does steps 1 to 3 itself. If Browsershot can't find Chrome or Node, tell it from a service provider: `BlockScreenshots::using(fn (Browsershot $shot) => $shot->setChromePath('/usr/bin/chromium'))` (`Aurora\Support\BlockScreenshots`). With Browsershot on the server, AI apps connected over MCP get a `generate_block_screenshots` tool that saves them as if uploaded under Settings › Blocks, where editors can also replace a picture by hand.
**Fields for the inspector.** Beyond Filament's own fields, use Aurora's helpers so blocks look alike in the editor:
- `Fields::icon()` (an icon picker of Heroicons; draw it with `svg(SiteIcon::name($data['icon'] ?? null) ?? 'hero-o-sparkles', 'size-6')`, `style: 'm'` for the small set; a site with an icon set of its own, such as Font Awesome, picks from that instead: `IconPickerField::make('icon')->allowedSets(['fontawesome-solid', 'fontawesome-brands'])`, drawn the same way), `Fields::tone()` (a colour choice; check a stored one with `array_key_exists($tone, Fields::TONES)`), `Fields::link()` (a page or address, a label and a new-tab switch; drawn with `link_href()` / `link_target()`), `Fields::helpText('…')` (a quieter helper text, for `->helperText()`). `Aurora\Blocks\Fields`, `Aurora\Support\SiteIcon`.
- Images: `AssetPicker::make('image')` (`Aurora\Filament\Forms\Components\AssetPicker`) picks a file from the media library. Its value is an asset id, or an array when the editor cropped the image, so test it with `ImageEdits::assetId($value)` (`Aurora\Support\ImageEdits`), never `is_string()`. Draw it with `<x-aurora::media>`, or with `AssetLibrary::url($value, 'md')` (`thumb`, cropped to 480 × 360, or an image format's handle: `sm`, `md`, `lg` and those added under Settings › Media), `AssetLibrary::srcset($value)` and `AssetLibrary::alt($value)` (`Aurora\Support\AssetLibrary`) for markup of your own. While editing, give the empty placeholder `{!! InlineEditing::pickerAttributes('image') !!}` (`Aurora\Blocks\InlineEditing`): a click on it opens the media picker for that field.
- Pages: `Select::make('page_id')->options(fn (): array => Page::options())` picks a page; `Page::query()->live()` is the pages visitors may see, for a list of pages in a block.
- Records of the website's own (an employee, a product): a `Select` of them, `->options(fn (): array => Employee::query()->orderBy('name')->pluck('name', 'id')->all())`, and the view loads the record by id and draws nothing (or, while `is_editing()`, a hint) when it's gone. AI tools see the options too. The `aurora-porting` skill shows the whole pattern.
- Addresses typed by hand in a settings tab: validate them with `->rule(SafeUrl::rule())` and print them with `SafeUrl::href($url)` (`Aurora\Support\SafeUrl`), which refuses `javascript:` and the like.
**Brand colours.** Editors choose a brand and an accent colour under Settings › Website › Branding, per site, and every block should follow them, on the website and on the canvas, without a rebuild. Aurora makes eleven shades of each and prints them as CSS custom properties, `--color-brand-50` to `--color-brand-950` and `--color-accent-50` to `--color-accent-950`, with `@include('aurora::partials.brand-palette')` (`Aurora\Support\BrandPalette`). The shipped layouts include it; in a layout of your own, put it in `<head>` after the site's stylesheet so it wins. The visual editor's canvas gets it on its own. To follow them:
- In the site's stylesheet, define the same names as Tailwind theme colours, with the site's own colours as the defaults for a site that hasn't chosen any: `@theme { --color-brand-50: oklch(…); … --color-brand-950: oklch(…); --color-accent-50: …; … }`, all eleven shades of both. Use plain `@theme`, never `@theme inline`: inline bakes the values into the utilities, and the admin's colours no longer reach them.
- In blocks, paint everything that's the brand with those names: `bg-brand-600`, `text-brand-700`, `ring-accent-300`, `from-brand-500`, and in CSS `var(--color-brand-500)` or `color-mix(in oklch, var(--color-brand-500) 60%, transparent)`. Never a fixed colour or one of Tailwind's own (`violet-600`, `#7c3aed`) for it. Neutrals (body text, borders, greys) can stay the site's own.
- Keep contrast whatever colour is chosen: light shades (50–100) as backgrounds under dark text (700–900), a 600 button with white text; never text and background from the same shade range.
- Let editors pick a block's colour with `Fields::tone()` (brand, accent, neutral, dark, success, warning, danger; names, not colours) and turn the choice into whole class names in the view, so Tailwind finds them: `match ($tone) { 'accent' => 'bg-accent-50 text-accent-800', 'dark' => 'bg-zinc-900 text-white', default => 'bg-brand-50 text-brand-800' }`, after checking it with `array_key_exists($tone, Fields::TONES)`.
- Try it: change the colours under Settings › Website › Branding and look at the block on the canvas and on the site; anything that stays the old colour is painted with a fixed one.
**A form block.** Editors build forms under Content › Forms; a block shows one. Its view posts to Aurora with a few hidden fields and reads errors and the thank-you state from the session: see [references/forms.md](references/forms.md) before writing one.
The page builder's own blocks (section, text, list, quote, image, button, video, embed, spacer, divider, shortcode, code) are drawn with its stylesheet, which a site's layout doesn't load. Leave out the ones the site's stylesheet doesn't style: `Blocks::forget('text', 'quote', 'divider')` in a service provider (registering a type again brings it back).
`<x-aurora::blocks :page="$page" class="flex flex-col gap-24" />` passes attributes to the element around the blocks; `:blocks="$blocks"` draws a list of blocks instead of a page's (in the language being shown; a container left out takes its blocks along).
Translating: a block's text fields (text, multi-line, rich text, Markdown, tags, and lists whose items have text) are translated; everything else is the same in every language. A text field that's the same in every language too (a record's id or a handle in a `TextInput`) goes in `sameInEveryLanguage()`. Text that's the same in a translation (a name) counts as translated once the translator chooses "Same in …" in the inspector; `BlockTranslations::extract($structure, $translated, $same)` keeps it from code.
## The website's settings
Settings the website's templates read (a header button, footer text, social links) are tabs on Settings' pages, registered from a service provider. Keys listed in `siteKeys` are kept per site:
```php
use Aurora\Support\SettingsTabs;
use Filament\Schemas\Components\Tabs\Tab;
SettingsTabs::register('footer', fn (): Tab => Tab::make(__('Footer'))->schema([
TextInput::make('footer_note')->label(__('Footer note')),
]), siteKeys: ['footer_note'], page: 'website');
```
**Gather them; don't add sidebar items.** Settings has a side menu of pages, each with tabs. Give the website's settings a tab per section on a page that's already there, where an editor would look:
- The site's own (header, footer, social links, its events' or shop's settings): tabs on **Website** (`page: 'website'`), one per section.
- A connection to another service (Mailchimp, Brevo, Shopify, a booking or analytics provider): a tab named after the service on **Integrations** (`page: 'integrations'`).
- Otherwise the page it belongs with: `templates`, `media`, `search-seo`, `performance`, `security`, `appearance`.
- An area of its own with several tabs (a shop: shipping, payments, taxes): one page for them all, `SettingsTabs::page('shop', fn (): string => __('Shop'), 'phosphor-storefront')` (after Website; `after:` another page), its tabs registered with `page: 'shop'`.
Without `page:`, each tab becomes a sidebar item of its own: only when the user asks for one.
**Secrets** (an API key, a token, a webhook secret) editors enter go in a `SecretInput` (`Aurora\Filament\Forms\Components\SecretInput`), its key listed in `secretKeys`: stored encrypted, never sent to the browser (the field shows its last four characters), kept when the field is left empty, never public even if listed in `publicKeys`. Read it with `Aurora\Support\SettingSecrets::get('mailchimp_api_key')`. A plain `TextInput` would send the value to the browser with the form. Secrets set once per server rather than by editors stay in `.env` and `config/services.php`.
```php
SettingsTabs::register('mailchimp', fn (): Tab => Tab::make(__('Mailchimp'))->schema([
SecretInput::make('mailchimp_api_key')->label(__('API key')),
TextInput::make('mailchimp_audience')->label(__('Audience ID')),
]), page: 'integrations', secretKeys: ['mailchimp_api_key']);
```
Read them in templates with `Aurora\Models\Setting::get('footer_note')`.
## Page layouts
Each page is drawn by its layout (`Aurora\Models\Layout`, set up under Pages › More › Layouts), which names a Blade view. Aurora ships two, `aurora::layouts.standard` (the default: header, menu and footer) and `aurora::layouts.blank` (the page alone), published to `resources/views/vendor/aurora/layouts` on install (or with `php artisan vendor:publish --tag=aurora-layouts`) to make them the site's. A layout view gets `$page` (an `Aurora\Models\Page`), `$layout`, `$settings`, `$preview` and `$embedded` (true inside the visual editor). Its blocks render through `<x-aurora::blocks :page="$page" />`; keep the markup around them in the view. For a new layout, write a view anywhere in the app (`layouts.campaign`) and add a layout naming it in the Layouts dialog. Or define it in code, as a component library does: a class extending `Aurora\Layouts\PageLayout` (`handle()`, `name()`, `view()`, and optionally `description()`, `icon()` and `schema()` for its own fields), registered from a service provider's `boot()` with `Layouts::register([CampaignLayout::class])`, or found on their own in `config('aurora.layout_path')` (`app/Layouts`). `php artisan make:aurora-layout "Campaign"` (`--description=`, `--icon=`) makes the class there and its view, `resources/views/layouts/campaign.blade.php`, from the site's standard layout. It's offered with the layouts added in the admin and can be made the default there, but not edited or deleted; changing `handle()` makes it a new layout. A layout that needs settings of its own that aren't blocks (a background image) registers fields for its view in a service provider's `boot()`: `Layouts::schema('layouts.campaign', fn (): array => [AssetPicker::make('background')->label(__('Background image'))])` (`Aurora\Support\Layouts`, `Aurora\Filament\Forms\Components\AssetPicker`). The page form shows them under the layout picker, they're saved in the page's `settings` column by view (another view's field of the same name is kept apart), and the view reads its own from `$settings` (`$settings['background'] ?? null`): always with a fallback, as pages saved before the fields existed don't have them. Elsewhere, `$page->layoutSettings()` gives the page's values for its current layout and `$page->layoutSetting('background', $default)` one of them. AI tools see and set them too (`list_layouts`, `get_page`, `layout_settings` on `create_page` and `update_page`), so give each field a clear label and helper text.
**What a layout draws around the blocks.** Start from the published standard layout, which does each of these:
- The head: `<title>{{ $page->seoTitle() }} · {{ Setting::get('site_name', config('app.name')) }}</title>`, the description `$page->meta_description ?: $page->excerpt`, and `<meta name="robots" content="noindex">` when `$page->noindex || $page->isRestricted() || $preview || $embedded` (a password-protected page, a preview and the canvas stay out of search). For sharing, `$page->og_image` (an asset: `AssetLibrary::url($page->og_image, 'og')`) and `$page->url()` as the canonical address; `$page->availableLocales()` with `$page->url($code)` for a language switcher and `hreflang` links.
- Menus: `Menu::location('header')` (`Aurora\Models\Menu`; `footer` too, and locations of the site's own, added under Menus › More › Manage locations) is the menu chosen for that place, or null. Draw `$menu->menuItems`, each with `title`, `url`, `target`, `icon` (`SiteIcon::name($item->icon)`) and `children`; `$item->isActive()` and `isActiveOrHasActiveChild()` mark the current page.
- An edit link for signed-in editors: `PageResource::editorUrl($page)` opens the page in the visual editor and `PageResource::settingsUrl($page)` its form (`Aurora\Filament\Resources\Pages\PageResource`), shown only when `auth()->user()?->can('update', $page)`.
## The visual editor's canvas
The canvas draws blocks with the website's stylesheet, wrapped in `@scope (.fpb-canvas)` (`Aurora\Support\CanvasStyles`):
- `:root`, `html`, `body` and `.fpb-canvas` itself are the canvas, and combinators are kept: `body > .fpb-block` is a block straight on the canvas. Target canvas blocks by `:scope > .fpb-block`, `.fpb-block[data-type="hero"]` and `.fpb-block-body` (the block's own markup); on the website, a top-level `:scope` is `:root`, so such rules do nothing there.
- The page's background and text colour (on `html`/`body`, in a layer or not) become the canvas's `--fpb-canvas-bg` and `--fpb-canvas-fg`; set those two on `html` to choose them yourself.
- Width breakpoints (`@media (width >= 48rem)`, `min-width`, `max-width`) are asked of the canvas (`@container fpb-canvas`), so the Tablet and Mobile previews show the site's tablet and mobile layouts; `vw` is the canvas's width (`cqw`). Other media queries (print, colour scheme, hover) stay as they are.
- Fonts: load them in the stylesheet (`@import url(…)` first, or `@font-face`) or with Laravel's Vite font loading. A `<link>` in the layout's `<head>` doesn't reach the canvas.
## The admin theme
Aurora's admin comes with a prebuilt theme, Borealis (`shipaurora/borealis`); run `php artisan filament:assets` after installing or updating Aurora. A different look for the CMS is a theme package of its own: it registers a Filament theme (`FilamentAsset::register([Theme::make('acme', …)])`), and `config('aurora.theme')` names its id. The admin theme is only the CMS's look: the visual editor shows blocks in the website's own stylesheet (`config('aurora.website_css')`, default `resources/css/app.css`), found through Vite and scoped to the canvas, so nothing needs compiling for it. Style the page itself through `:root`, `html` and `body` in that stylesheet rather than classes on `<body>`, so the canvas gets it too.
## The website's own screens, models and routes
- **Admin screens:** register the website's Filament resources and pages in a service provider's `register()` method, never `boot()`: Aurora's admin panel is built while the app's providers boot, before the app's own `boot()` runs, and keeps the screens and groups it was given then, so ones added in `boot()` never show. `AdminPanel::discover(app_path('Filament/Resources'), 'App\Filament\Resources')`, or `AdminPanel::resources([...])` / `::pages([...])` (`Aurora\Support\AdminPanel`). The admin is per site (`/admin/{site}/…`): resources show every site's records unless registered with `perSite: true`, for a model with a `site` relation. Put them in a group of their own with `AdminPanel::navigationGroup('Events', fn (): string => __('Events'))`, after Content by default (`after: 'AI'`, or another of the website's).
- **Filament plugins:** a package's plugin (or the website's own) joins Aurora's admin with `AdminPanel::plugins([fn (): Plugin => ShopPlugin::make()->currency('NOK')])` in `register()` too, never by editing Aurora's panel or the starter app's providers. A closure configures the plugin when the panel is built. The resources a plugin brings show every site's records unless it's registered with `perSite: true` (its models have a `site` relation). A plugin that registers a panel of its own, or changes Filament's defaults for every panel, doesn't belong in Aurora's admin.
- **Permissions and policies:** every model of the website's own has a policy (`php artisan make:policy EventPolicy --model=Event`, found by Laravel's naming) that checks permissions of its own, named "{ability} {Model}" as Aurora's are, so roles control it as finely as pages: `return $user->can('update Event');`. Register them in a service provider with `Permissions::register('Events', ['view-any Event' => ['See events', 'Open the event list.'], 'create Event' => […], 'update Event' => […], 'delete Event' => […]], 'phosphor-calendar', 'The website\'s events.', grantTo: ['Editor'])` (`Aurora\Support\Permissions`): they're in Users & roles beside Aurora's, and the roles in `grantTo` get them when they first exist. Filament's resources use the policy by themselves; so do the website's MCP tools. On a site that's already installed, `php artisan aurora:sync-permissions` creates new permissions (put it in the deploy script); Super Admins pass every check.
- **The website's models in AI apps:** every model in `app/Models` is offered to AI apps and the in-panel assistant automatically (`Aurora\Support\AiModels`), through `list_models`, `list_records`, `get_record`, `save_record` and `delete_record`. Its policy decides who: someone sees the model when `viewAny` allows, and each read, create, change and delete asks `view`, `create`, `update` or `delete`. A model without a policy is for Super Admins only, so give every model a policy. Fields in `$hidden` and encrypted casts are never shown or written, only fillable fields are written, and a model's Filament resource lends its form's labels, choices and required fields. `#[HiddenFromAi]` (`Aurora\Mcp\HiddenFromAi`) on the model keeps it out entirely; `aurora.mcp.models` lists the folders searched.
- **AI tools (MCP):** for what the record tools don't cover (an action such as "send the newsletter", a search across models), the website's own tools: a class extending `Aurora\Mcp\Tools\CmsTool` with `#[Name('list_events')]`, `#[Description(…)]`, `#[IsReadOnly]` when it only reads, a `schema()` and `run(Request $request, User $user): Response`, which authorizes with the model's policy (`Gate::authorize('update', $event)`) and returns `Response::json([...])` (a page argument is found with `$this->findPage($request)`: an id or the page's address), or `Response::error('…')` as a message the AI can act on. `protected bool $needsSite = false;` for what isn't one site's. Registered in a service provider with `McpTools::register([ListEvents::class, UpdateEvent::class])` (`Aurora\Support\McpTools`). It acts as the person who connected, so their permissions apply, and every call is in the AI activity log.
- **Dashboard widgets:** any Filament widget, the website's own or a package's, with `AdminPanel::widgets([UpcomingEvents::class])` in `register()` (`AdminPanel::discover()` finds widgets too). People add it to their own dashboard under *Customise dashboard*; new widgets show at the end of everyone's. Its default place is its `$sort`, its default size its `$columnSpan` (`'full'` is large, 2 medium, 1 small), who sees it its `canView()`. Give it static `getDashboardLabel()` and `getDashboardDescription()` for its name and sentence in the customise view, else it's named after its class.
- **Publishing:** a model published like a page uses `Aurora\Models\Concerns\HasPublishing` (`Event::publishingColumns($table)` in its migration; `Event::live()`, `isLive()`, `isScheduled()`, `hasEnded()`), and `Aurora\Filament\Publishing::section()`, `::column()` and `::filter()` in its resource.
- **Routes and languages:** `Aurora\Http\Middleware\UseSiteLocale` runs on every web route: an address starting with a language's prefix (`/en/events`) sets that language (`app()->getLocale()`, `Aurora\Support\ContentLocale::current()`). Define a localized route under each prefix (`ContentLocale::prefix($code)` gives it, empty for the default language) next to the default one. A page's address in a language is `$page->path($locale)` (or `url($locale)`); page routes take every other address, so register website routes in the app's `routes/web.php`, which loads first. A website route's view gets the site's layout around its own content with `<x-aurora::layout :title="__('Events')" description="…">…</x-aurora::layout>` (`layout="landing"` for another layout than the default, `:settings` for its fields): the layout gets a stand-in page with that title and description and the request's address (`Page::standIn()`), and the content stands where its blocks would; `current_page()` inside answers it. From a controller, `Layouts::wrap($html, 'Events')` returns the same. Give the website's own routes the `Aurora\Http\Middleware\RestrictWebsiteAccess` middleware, as page routes have, so a site that isn't open to everyone yet stays closed there too.
- **Menus:** add menu builder panels for the website's routes or models (`Datlechin\FilamentMenuBuilder\MenuPanel\StaticMenuPanel`, `ModelMenuPanel` for a model implementing `MenuPanelable`) with `Menus::panels([...])`, in `register()` too (the menu editor gets its panels when the admin panel is built), and name the paths every language has under its prefix with `Menus::localized(['/events'])`: a menu link to `/events` then goes to `/en/events` in English (`Aurora\Support\Menus`).
- **Shortcodes:** editors write their own in Settings › Website › Shortcodes (`[phone]` and its text, the same on every site, printed as text; `Aurora\Support\CustomShortcodes`).
- **Sitemap:** `Sitemap::add(fn (): iterable => [...])` adds the website's addresses to `sitemap.xml`, each a URL or `['url' => …, 'updated_at' => …, 'priority' => 0.6]` (`Aurora\Support\Sitemap`).
- **`register()` or `boot()`:** `AdminPanel::*()` and `Menus::panels()` go in `register()`, as above; everything else (`Blocks::register()`, `SettingsTabs::register()`, `Layouts::schema()`, `Sitemap::add()`, `Themes::register()`, routes) works from `boot()`.
- **Labels:** the admin's labels come from, in order: what's edited in Administration › Translations, the app's `lang/{locale}.json`, then Aurora's and the packages'. So a site's own `"Events": "Events"` in `lang/nb.json` is its label at once, whatever Aurora's says; `aurora:import-translations` only lists labels in the Translations screen.
## Importing content
Pages are `Aurora\Models\Page` records: `title`, `slug`, `excerpt`, `meta_title` and `meta_description` are JSON per language (`setTranslation('slug', 'nb', 'om-oss')`); `blocks` is one flat list for every language, each `['id', 'type', 'data', 'parent', 'slot', 'settings']` (`Aurora\Support\BlockTreeBuilder::flatten()` makes it from a nested tree, with `'children' => ['slot' => [...]]`); `block_translations` is `{"nb": {"<block id>": {"<field>": "…"}}}`, text only; `settings` holds the layout's fields, `{"layouts": {"<view>": {…}}}`, written with `$page->settingsWithLayoutValues($values, 'campaign')` (a layout or its handle). Status and dates are `status`, `published_at` and `unpublish_at`.
- Files: `AssetLibrary::fromFile($path, ['id' => $uuid, 'title' => …, 'alt' => …, 'folder' => 'Photos/2026'])`. With an `id` (say one made from the source's), running the import again finds the asset instead of adding another. The largest file is `aurora.media.max_file_mb` (50 MB; images `max_image_mb`, 10 MB).
- Saves from code go straight on the page, live or not. Only the page form, the visual editor and AI tools keep a live page's changes as unpublished changes (`Aurora\Support\WorkingCopies`).
- Addresses: a page's is its parents' slugs and its own (`parent_id`), found part by part (`Page::findByPath('services/design', $locale)`), so pages in different places may share a slug; only pages under the same parent (or at the top, which includes those under the homepage) need different ones: `Page::uniqueSlug($title, $locale, parentId: $parent?->id)`. Import parents before their children.
- Page templates: pages new pages can start from (their layout, layout fields, blocks and translations, copied; a page made from one keeps `template_id`). They're `Aurora\Models\PageTemplate`, in the pages table but never a `Page`: `Page` queries never return one and `PageTemplate` queries never return a page, so the website, its APIs, search, menus and AI tools never see them. Make them with `Aurora\Support\PageTemplates::create($name, $description, $layoutId)` or `::fromPage($page, $name)` (then fill `blocks` like a page's), and copy one's content into a new page with `PageTemplates::content($template)`. Editors pick one in "New page"; designers manage them under Settings › Templates › Page templates, or the page list's More menu, with the "Manage page templates" permission.
- Redirects are `Aurora\Models\Redirect` (`from_path`, and `page_id`, `asset_id` or `to_url`, with `status_code`); one that would send visitors round in a circle (`/a` → `/b` → `/a`, through other redirects, until a live page answers) isn't saved: saving throws a `ValidationException` saying which addresses; an import should catch it and report the redirect rather than stop. menus are `Aurora\Models\Menu` with `MenuItem`s (`linkable` a page, or a `url`).
## Search
Pages are searchable with Laravel Scout, out of the box: on save, Aurora keeps what a live page says (titles, excerpt, meta description and its blocks' text, in every language) for the index. Search the live pages visitors may see on the current site (the one the request's address is for) with `Aurora\Models\Page::search($terms)` (a Scout builder: `->paginate(10)`, `->get()`; `->query()` adds to its limits, never lifts them), or another site's with `Page::searchSite($terms, site: $site)`. By default it uses the database, with full-text search on MySQL; set `SCOUT_DRIVER` (meilisearch, typesense, algolia) for a search service, then `php artisan scout:import "Aurora\Models\Page"`. Each page's "Reindex" action (and the bulk one) indexes pages again.
## Configuration and commands
- Publish the config with `php artisan vendor:publish --tag=aurora-config`: the CMS name (`AURORA_NAME`), demo mode (`AURORA_DEMO_LOGIN`) and `translations_path`.
- `php artisan aurora:install` sets up the CMS (database, roles, a first admin), then offers to run the website's own seeders (`database/seeders/DatabaseSeeder`; `--seed` runs them without asking). A website's own content, such as its first homepage, belongs in those seeders, never in Aurora. For demo mode's one-click sign-in (`AURORA_DEMO_LOGIN`), a website registers its accounts from a service provider: `Aurora\Support\Demo::register([['name' => …, 'email' => …, 'role' => …]])`; only those accounts are offered.
- `php artisan aurora:import-translations` loads labels into the admin's Translations screen; `aurora:sync-translations` adds new labels from the code; `aurora:export-translations` writes edited labels back to the lang files.
Aurora is the CMS; the app is the website. The website gives Aurora its blocks, its settings tabs and its page view through the hooks below, from its own service provider. Never import the packages Aurora builds on (the page builder, Filament plugins) in website code: use the Aurora\ classes, so Aurora can change underneath without breaking the site.
A block is a class and a Blade view. Create both with php artisan aurora:make-block "Pricing table" (--container for a block that holds other blocks, --category=, --icon=), then fill them in.
Aurora\Blocks\Block (in config('aurora.block_path'), default app/Blocks): type() (a short slug, also the view name), label(), description(), schema() (Filament fields for the inspector), and optionally icon() (a Phosphor icon, e.g. phosphor-chart-bar), category() (layout, content, marketing, media, design or developer), defaults() (the data a new block starts with: make it look finished), editables() (text editable on the canvas: Aurora\Blocks\Editable::text(), ::richText() for a RichEditor field, which gets bold, italic, underline, strikethrough, links, H2/H3, lists and quotes right on the page (anything more, such as a table, is edited in the inspector), ->multiline(), ->placeholder(); mark the element with @editable('field') and draw it with @shows so an empty field can still be clicked), itemEditables() (the same for the fields of a repeater's items, ['items' => ['question' => Editable::text()]]; mark each with @editableItem('items', $loop->index, 'question')), aiHints() (what the AI should know when filling it in), sample() (the block as its screenshot shows it: defaults(), and for a container the blocks in it, [...parent::sample(), 'children' => ['items' => [['type' => 'feature', 'data' => FeatureBlock::defaults()]]]]) and preview() (the picture beside it in the visual editor's block list: by default the site's screenshot, below; a block package returns an image URL of its own).resources/views/components/blocks/{type}.blade.php with @props(['data' => []]). The same view draws the block on the website, in the visual editor and in previews. Mark canvas-editable text with @editable('field'). Render fields with the global helpers, no @php use imports needed: inline_text() (with *highlighted* words), plain_text(), rich_text(), shows_field('body', $data['body'] ?? null) for optional fields, link_href($data) / link_target($data) for a Fields::link() group, and is_editing() to skip animations or show placeholders while editing (each is a short form of Aurora\Support\Markup, Aurora\Blocks\Fields or Aurora\Blocks\Blocks). Each also has a Blade directive (@inlineText(…), @plainText, @richText, @linkHref, @linkTarget, @blockSlot('name'), and @editing … @endediting, @shows('body', $data['body'] ?? null) … @endshows). Render media library images with <x-aurora::media :value="$data['image'] ?? null" size="md" />.Aurora\Blocks\Contracts\Container (slots() and defaults()) and draws each slot with {{ block_slot('name') }}. A block view can ask where it is: current_page() (the Aurora\Models\Page being drawn, on the website, in previews and on the canvas; null in a palette preview), block_depth() (@blockDepth, 0 straight on the page, 1 in a column) and is_nested_block() (@nested … @endnested), from Aurora\Blocks\Blocks::page(), ::depth() and ::isNested().config('aurora.block_path') are registered on their own. To set their order in the block palette, list them in a service provider's boot() (the rest follow):use Aurora\Blocks\Blocks;
Blocks::register([HeroBlock::class, StatBlock::class]);
Labels and descriptions go through __() and need a line in the app's lang/{locale}.json.
Give every block a screenshot. Editors pick blocks by their picture in the visual editor's block list, and a block without one shows only an icon. Each is a screenshot of the block alone, drawn from its example data (sample(), which is defaults() unless a container adds the blocks in it), committed with the site's code as resources/block-previews/{type}.webp; Block::preview() finds it on its own. Until a block has one, the block list shows the block itself, live from its sample page, which is heavier to load and needs the block's JavaScript, so a screenshot is still worth committing. So make defaults() look like real, finished content, and take the screenshot again when the block's look changes. When asked to generate the missing block screenshots (or after making a block):
php artisan aurora:block-previews --missing --json lists the blocks without one: each with its sample_url (the block alone with the site's stylesheet, at a signed address that works for two hours without signing in) and the file to write.sample_url in a 1600×900 window with the browser you have (a browser tool, Playwright, or headless Chrome: "<chrome>" --headless=new --hide-scrollbars --window-size=1600,900 --screenshot=/tmp/hero.png "<sample_url>"). The site must be running at its APP_URL.php artisan aurora:block-previews hero --from=/tmp/hero.png saves it as the block list wants it: 800×450 WebP in resources/block-previews. Look at it, and fix the block's defaults() if it looks empty or broken.With spatie/browsershot installed (optional; it needs Chrome or Chromium and Node's puppeteer), php artisan aurora:block-previews --missing --generate does steps 1 to 3 itself. If Browsershot can't find Chrome or Node, tell it from a service provider: BlockScreenshots::using(fn (Browsershot $shot) => $shot->setChromePath('/usr/bin/chromium')) (Aurora\Support\BlockScreenshots). With Browsershot on the server, AI apps connected over MCP get a generate_block_screenshots tool that saves them as if uploaded under Settings › Blocks, where editors can also replace a picture by hand.
Fields for the inspector. Beyond Filament's own fields, use Aurora's helpers so blocks look alike in the editor:
Fields::icon() (an icon picker of Heroicons; draw it with svg(SiteIcon::name($data['icon'] ?? null) ?? 'hero-o-sparkles', 'size-6'), style: 'm' for the small set; a site with an icon set of its own, such as Font Awesome, picks from that instead: IconPickerField::make('icon')->allowedSets(['fontawesome-solid', 'fontawesome-brands']), drawn the same way), Fields::tone() (a colour choice; check a stored one with array_key_exists($tone, Fields::TONES)), Fields::link() (a page or address, a label and a new-tab switch; drawn with link_href() / link_target()), Fields::helpText('…') (a quieter helper text, for ->helperText()). Aurora\Blocks\Fields, Aurora\Support\SiteIcon.AssetPicker::make('image') (Aurora\Filament\Forms\Components\AssetPicker) picks a file from the media library. Its value is an asset id, or an array when the editor cropped the image, so test it with ImageEdits::assetId($value) (Aurora\Support\ImageEdits), never is_string(). Draw it with <x-aurora::media>, or with AssetLibrary::url($value, 'md') (thumb, cropped to 480 × 360, or an image format's handle: sm, md, lg and those added under Settings › Media), AssetLibrary::srcset($value) and AssetLibrary::alt($value) (Aurora\Support\AssetLibrary) for markup of your own. While editing, give the empty placeholder {!! InlineEditing::pickerAttributes('image') !!} (Aurora\Blocks\InlineEditing): a click on it opens the media picker for that field.Select::make('page_id')->options(fn (): array => Page::options()) picks a page; Page::query()->live() is the pages visitors may see, for a list of pages in a block.Select of them, ->options(fn (): array => Employee::query()->orderBy('name')->pluck('name', 'id')->all()), and the view loads the record by id and draws nothing (or, while is_editing(), a hint) when it's gone. AI tools see the options too. The aurora-porting skill shows the whole pattern.->rule(SafeUrl::rule()) and print them with SafeUrl::href($url) (Aurora\Support\SafeUrl), which refuses javascript: and the like.Brand colours. Editors choose a brand and an accent colour under Settings › Website › Branding, per site, and every block should follow them, on the website and on the canvas, without a rebuild. Aurora makes eleven shades of each and prints them as CSS custom properties, --color-brand-50 to --color-brand-950 and --color-accent-50 to --color-accent-950, with @include('aurora::partials.brand-palette') (Aurora\Support\BrandPalette). The shipped layouts include it; in a layout of your own, put it in <head> after the site's stylesheet so it wins. The visual editor's canvas gets it on its own. To follow them:
@theme { --color-brand-50: oklch(…); … --color-brand-950: oklch(…); --color-accent-50: …; … }, all eleven shades of both. Use plain @theme, never @theme inline: inline bakes the values into the utilities, and the admin's colours no longer reach them.bg-brand-600, text-brand-700, ring-accent-300, from-brand-500, and in CSS var(--color-brand-500) or color-mix(in oklch, var(--color-brand-500) 60%, transparent). Never a fixed colour or one of Tailwind's own (violet-600, #7c3aed) for it. Neutrals (body text, borders, greys) can stay the site's own.Fields::tone() (brand, accent, neutral, dark, success, warning, danger; names, not colours) and turn the choice into whole class names in the view, so Tailwind finds them: match ($tone) { 'accent' => 'bg-accent-50 text-accent-800', 'dark' => 'bg-zinc-900 text-white', default => 'bg-brand-50 text-brand-800' }, after checking it with array_key_exists($tone, Fields::TONES).A form block. Editors build forms under Content › Forms; a block shows one. Its view posts to Aurora with a few hidden fields and reads errors and the thank-you state from the session: see references/forms.md before writing one.
The page builder's own blocks (section, text, list, quote, image, button, video, embed, spacer, divider, shortcode, code) are drawn with its stylesheet, which a site's layout doesn't load. Leave out the ones the site's stylesheet doesn't style: Blocks::forget('text', 'quote', 'divider') in a service provider (registering a type again brings it back).
<x-aurora::blocks :page="$page" class="flex flex-col gap-24" /> passes attributes to the element around the blocks; :blocks="$blocks" draws a list of blocks instead of a page's (in the language being shown; a container left out takes its blocks along).
Translating: a block's text fields (text, multi-line, rich text, Markdown, tags, and lists whose items have text) are translated; everything else is the same in every language. A text field that's the same in every language too (a record's id or a handle in a TextInput) goes in sameInEveryLanguage(). Text that's the same in a translation (a name) counts as translated once the translator chooses "Same in …" in the inspector; BlockTranslations::extract($structure, $translated, $same) keeps it from code.
Settings the website's templates read (a header button, footer text, social links) are tabs on Settings' pages, registered from a service provider. Keys listed in siteKeys are kept per site:
use Aurora\Support\SettingsTabs;
use Filament\Schemas\Components\Tabs\Tab;
SettingsTabs::register('footer', fn (): Tab => Tab::make(__('Footer'))->schema([
TextInput::make('footer_note')->label(__('Footer note')),
]), siteKeys: ['footer_note'], page: 'website');
Gather them; don't add sidebar items. Settings has a side menu of pages, each with tabs. Give the website's settings a tab per section on a page that's already there, where an editor would look:
page: 'website'), one per section.page: 'integrations').templates, media, search-seo, performance, security, appearance.SettingsTabs::page('shop', fn (): string => __('Shop'), 'phosphor-storefront') (after Website; after: another page), its tabs registered with page: 'shop'.Without page:, each tab becomes a sidebar item of its own: only when the user asks for one.
Secrets (an API key, a token, a webhook secret) editors enter go in a SecretInput (Aurora\Filament\Forms\Components\SecretInput), its key listed in secretKeys: stored encrypted, never sent to the browser (the field shows its last four characters), kept when the field is left empty, never public even if listed in publicKeys. Read it with Aurora\Support\SettingSecrets::get('mailchimp_api_key'). A plain TextInput would send the value to the browser with the form. Secrets set once per server rather than by editors stay in .env and config/services.php.
SettingsTabs::register('mailchimp', fn (): Tab => Tab::make(__('Mailchimp'))->schema([
SecretInput::make('mailchimp_api_key')->label(__('API key')),
TextInput::make('mailchimp_audience')->label(__('Audience ID')),
]), page: 'integrations', secretKeys: ['mailchimp_api_key']);
Read them in templates with Aurora\Models\Setting::get('footer_note').
Each page is drawn by its layout (Aurora\Models\Layout, set up under Pages › More › Layouts), which names a Blade view. Aurora ships two, aurora::layouts.standard (the default: header, menu and footer) and aurora::layouts.blank (the page alone), published to resources/views/vendor/aurora/layouts on install (or with php artisan vendor:publish --tag=aurora-layouts) to make them the site's. A layout view gets $page (an Aurora\Models\Page), $layout, $settings, $preview and $embedded (true inside the visual editor). Its blocks render through <x-aurora::blocks :page="$page" />; keep the markup around them in the view. For a new layout, write a view anywhere in the app (layouts.campaign) and add a layout naming it in the Layouts dialog. Or define it in code, as a component library does: a class extending Aurora\Layouts\PageLayout (handle(), name(), view(), and optionally description(), icon() and schema() for its own fields), registered from a service provider's boot() with Layouts::register([CampaignLayout::class]), or found on their own in config('aurora.layout_path') (app/Layouts). php artisan make:aurora-layout "Campaign" (--description=, --icon=) makes the class there and its view, resources/views/layouts/campaign.blade.php, from the site's standard layout. It's offered with the layouts added in the admin and can be made the default there, but not edited or deleted; changing handle() makes it a new layout. A layout that needs settings of its own that aren't blocks (a background image) registers fields for its view in a service provider's boot(): Layouts::schema('layouts.campaign', fn (): array => [AssetPicker::make('background')->label(__('Background image'))]) (Aurora\Support\Layouts, Aurora\Filament\Forms\Components\AssetPicker). The page form shows them under the layout picker, they're saved in the page's settings column by view (another view's field of the same name is kept apart), and the view reads its own from $settings ($settings['background'] ?? null): always with a fallback, as pages saved before the fields existed don't have them. Elsewhere, $page->layoutSettings() gives the page's values for its current layout and $page->layoutSetting('background', $default) one of them. AI tools see and set them too (list_layouts, get_page, layout_settings on create_page and update_page), so give each field a clear label and helper text.
What a layout draws around the blocks. Start from the published standard layout, which does each of these:
<title>{{ $page->seoTitle() }} · {{ Setting::get('site_name', config('app.name')) }}</title>, the description $page->meta_description ?: $page->excerpt, and <meta name="robots" content="noindex"> when $page->noindex || $page->isRestricted() || $preview || $embedded (a password-protected page, a preview and the canvas stay out of search). For sharing, $page->og_image (an asset: AssetLibrary::url($page->og_image, 'og')) and $page->url() as the canonical address; $page->availableLocales() with $page->url($code) for a language switcher and hreflang links.Menu::location('header') (Aurora\Models\Menu; footer too, and locations of the site's own, added under Menus › More › Manage locations) is the menu chosen for that place, or null. Draw $menu->menuItems, each with title, url, target, icon (SiteIcon::name($item->icon)) and children; $item->isActive() and isActiveOrHasActiveChild() mark the current page.PageResource::editorUrl($page) opens the page in the visual editor and PageResource::settingsUrl($page) its form (Aurora\Filament\Resources\Pages\PageResource), shown only when auth()->user()?->can('update', $page).The canvas draws blocks with the website's stylesheet, wrapped in @scope (.fpb-canvas) (Aurora\Support\CanvasStyles):
:root, html, body and .fpb-canvas itself are the canvas, and combinators are kept: body > .fpb-block is a block straight on the canvas. Target canvas blocks by :scope > .fpb-block, .fpb-block[data-type="hero"] and .fpb-block-body (the block's own markup); on the website, a top-level :scope is :root, so such rules do nothing there.html/body, in a layer or not) become the canvas's --fpb-canvas-bg and --fpb-canvas-fg; set those two on html to choose them yourself.@media (width >= 48rem), min-width, max-width) are asked of the canvas (@container fpb-canvas), so the Tablet and Mobile previews show the site's tablet and mobile layouts; vw is the canvas's width (cqw). Other media queries (print, colour scheme, hover) stay as they are.@import url(…) first, or @font-face) or with Laravel's Vite font loading. A <link> in the layout's <head> doesn't reach the canvas.Aurora's admin comes with a prebuilt theme, Borealis (shipaurora/borealis); run php artisan filament:assets after installing or updating Aurora. A different look for the CMS is a theme package of its own: it registers a Filament theme (FilamentAsset::register([Theme::make('acme', …)])), and config('aurora.theme') names its id. The admin theme is only the CMS's look: the visual editor shows blocks in the website's own stylesheet (config('aurora.website_css'), default resources/css/app.css), found through Vite and scoped to the canvas, so nothing needs compiling for it. Style the page itself through :root, html and body in that stylesheet rather than classes on <body>, so the canvas gets it too.
register() method, never boot(): Aurora's admin panel is built while the app's providers boot, before the app's own boot() runs, and keeps the screens and groups it was given then, so ones added in boot() never show. AdminPanel::discover(app_path('Filament/Resources'), 'App\Filament\Resources'), or AdminPanel::resources([...]) / ::pages([...]) (Aurora\Support\AdminPanel). The admin is per site (/admin/{site}/…): resources show every site's records unless registered with perSite: true, for a model with a site relation. Put them in a group of their own with AdminPanel::navigationGroup('Events', fn (): string => __('Events')), after Content by default (after: 'AI', or another of the website's).AdminPanel::plugins([fn (): Plugin => ShopPlugin::make()->currency('NOK')]) in register() too, never by editing Aurora's panel or the starter app's providers. A closure configures the plugin when the panel is built. The resources a plugin brings show every site's records unless it's registered with perSite: true (its models have a site relation). A plugin that registers a panel of its own, or changes Filament's defaults for every panel, doesn't belong in Aurora's admin.php artisan make:policy EventPolicy --model=Event, found by Laravel's naming) that checks permissions of its own, named "{ability} {Model}" as Aurora's are, so roles control it as finely as pages: return $user->can('update Event');. Register them in a service provider with Permissions::register('Events', ['view-any Event' => ['See events', 'Open the event list.'], 'create Event' => […], 'update Event' => […], 'delete Event' => […]], 'phosphor-calendar', 'The website\'s events.', grantTo: ['Editor']) (Aurora\Support\Permissions): they're in Users & roles beside Aurora's, and the roles in grantTo get them when they first exist. Filament's resources use the policy by themselves; so do the website's MCP tools. On a site that's already installed, php artisan aurora:sync-permissions creates new permissions (put it in the deploy script); Super Admins pass every check.app/Models is offered to AI apps and the in-panel assistant automatically (Aurora\Support\AiModels), through list_models, list_records, get_record, save_record and delete_record. Its policy decides who: someone sees the model when viewAny allows, and each read, create, change and delete asks view, create, update or delete. A model without a policy is for Super Admins only, so give every model a policy. Fields in $hidden and encrypted casts are never shown or written, only fillable fields are written, and a model's Filament resource lends its form's labels, choices and required fields. #[HiddenFromAi] (Aurora\Mcp\HiddenFromAi) on the model keeps it out entirely; aurora.mcp.models lists the folders searched.Aurora\Mcp\Tools\CmsTool with #[Name('list_events')], #[Description(…)], #[IsReadOnly] when it only reads, a schema() and run(Request $request, User $user): Response, which authorizes with the model's policy (Gate::authorize('update', $event)) and returns Response::json([...]) (a page argument is found with $this->findPage($request): an id or the page's address), or Response::error('…') as a message the AI can act on. protected bool $needsSite = false; for what isn't one site's. Registered in a service provider with McpTools::register([ListEvents::class, UpdateEvent::class]) (Aurora\Support\McpTools). It acts as the person who connected, so their permissions apply, and every call is in the AI activity log.AdminPanel::widgets([UpcomingEvents::class]) in register() (AdminPanel::discover() finds widgets too). People add it to their own dashboard under Customise dashboard; new widgets show at the end of everyone's. Its default place is its $sort, its default size its $columnSpan ('full' is large, 2 medium, 1 small), who sees it its canView(). Give it static getDashboardLabel() and getDashboardDescription() for its name and sentence in the customise view, else it's named after its class.Aurora\Models\Concerns\HasPublishing (Event::publishingColumns($table) in its migration; Event::live(), isLive(), isScheduled(), hasEnded()), and Aurora\Filament\Publishing::section(), ::column() and ::filter() in its resource.Aurora\Http\Middleware\UseSiteLocale runs on every web route: an address starting with a language's prefix (/en/events) sets that language (app()->getLocale(), Aurora\Support\ContentLocale::current()). Define a localized route under each prefix (ContentLocale::prefix($code) gives it, empty for the default language) next to the default one. A page's address in a language is $page->path($locale) (or url($locale)); page routes take every other address, so register website routes in the app's routes/web.php, which loads first. A website route's view gets the site's layout around its own content with <x-aurora::layout :title="__('Events')" description="…">…</x-aurora::layout> (layout="landing" for another layout than the default, :settings for its fields): the layout gets a stand-in page with that title and description and the request's address (Page::standIn()), and the content stands where its blocks would; current_page() inside answers it. From a controller, Layouts::wrap($html, 'Events') returns the same. Give the website's own routes the Aurora\Http\Middleware\RestrictWebsiteAccess middleware, as page routes have, so a site that isn't open to everyone yet stays closed there too.Datlechin\FilamentMenuBuilder\MenuPanel\StaticMenuPanel, ModelMenuPanel for a model implementing MenuPanelable) with Menus::panels([...]), in register() too (the menu editor gets its panels when the admin panel is built), and name the paths every language has under its prefix with Menus::localized(['/events']): a menu link to /events then goes to /en/events in English (Aurora\Support\Menus).[phone] and its text, the same on every site, printed as text; Aurora\Support\CustomShortcodes).Sitemap::add(fn (): iterable => [...]) adds the website's addresses to sitemap.xml, each a URL or ['url' => …, 'updated_at' => …, 'priority' => 0.6] (Aurora\Support\Sitemap).register() or boot(): AdminPanel::*() and Menus::panels() go in register(), as above; everything else (Blocks::register(), SettingsTabs::register(), Layouts::schema(), Sitemap::add(), Themes::register(), routes) works from boot().lang/{locale}.json, then Aurora's and the packages'. So a site's own "Events": "Events" in lang/nb.json is its label at once, whatever Aurora's says; aurora:import-translations only lists labels in the Translations screen.Pages are Aurora\Models\Page records: title, slug, excerpt, meta_title and meta_description are JSON per language (setTranslation('slug', 'nb', 'om-oss')); blocks is one flat list for every language, each ['id', 'type', 'data', 'parent', 'slot', 'settings'] (Aurora\Support\BlockTreeBuilder::flatten() makes it from a nested tree, with 'children' => ['slot' => [...]]); block_translations is {"nb": {"<block id>": {"<field>": "…"}}}, text only; settings holds the layout's fields, {"layouts": {"<view>": {…}}}, written with $page->settingsWithLayoutValues($values, 'campaign') (a layout or its handle). Status and dates are status, published_at and unpublish_at.
AssetLibrary::fromFile($path, ['id' => $uuid, 'title' => …, 'alt' => …, 'folder' => 'Photos/2026']). With an id (say one made from the source's), running the import again finds the asset instead of adding another. The largest file is aurora.media.max_file_mb (50 MB; images max_image_mb, 10 MB).Aurora\Support\WorkingCopies).parent_id), found part by part (Page::findByPath('services/design', $locale)), so pages in different places may share a slug; only pages under the same parent (or at the top, which includes those under the homepage) need different ones: Page::uniqueSlug($title, $locale, parentId: $parent?->id). Import parents before their children.template_id). They're Aurora\Models\PageTemplate, in the pages table but never a Page: Page queries never return one and PageTemplate queries never return a page, so the website, its APIs, search, menus and AI tools never see them. Make them with Aurora\Support\PageTemplates::create($name, $description, $layoutId) or ::fromPage($page, $name) (then fill blocks like a page's), and copy one's content into a new page with PageTemplates::content($template). Editors pick one in "New page"; designers manage them under Settings › Templates › Page templates, or the page list's More menu, with the "Manage page templates" permission.Aurora\Models\Redirect (from_path, and page_id, asset_id or to_url, with status_code); one that would send visitors round in a circle (/a → /b → /a, through other redirects, until a live page answers) isn't saved: saving throws a ValidationException saying which addresses; an import should catch it and report the redirect rather than stop. menus are Aurora\Models\Menu with MenuItems (linkable a page, or a url).Pages are searchable with Laravel Scout, out of the box: on save, Aurora keeps what a live page says (titles, excerpt, meta description and its blocks' text, in every language) for the index. Search the live pages visitors may see on the current site (the one the request's address is for) with Aurora\Models\Page::search($terms) (a Scout builder: ->paginate(10), ->get(); ->query() adds to its limits, never lifts them), or another site's with Page::searchSite($terms, site: $site). By default it uses the database, with full-text search on MySQL; set SCOUT_DRIVER (meilisearch, typesense, algolia) for a search service, then php artisan scout:import "Aurora\Models\Page". Each page's "Reindex" action (and the bulk one) indexes pages again.
php artisan vendor:publish --tag=aurora-config: the CMS name (AURORA_NAME), demo mode (AURORA_DEMO_LOGIN) and translations_path.php artisan aurora:install sets up the CMS (database, roles, a first admin), then offers to run the website's own seeders (database/seeders/DatabaseSeeder; --seed runs them without asking). A website's own content, such as its first homepage, belongs in those seeders, never in Aurora. For demo mode's one-click sign-in (AURORA_DEMO_LOGIN), a website registers its accounts from a service provider: Aurora\Support\Demo::register([['name' => …, 'email' => …, 'role' => …]]); only those accounts are offered.php artisan aurora:import-translations loads labels into the admin's Translations screen; aurora:sync-translations adds new labels from the code; aurora:export-translations writes edited labels back to the lang files.How can I help you explore Laravel packages today?