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/aurora
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; draw it with `svg(SiteIcon::name($data['icon'] ?? null) ?? 'hero-o-sparkles', 'size-6')`, `style: 'm'` for the small set), `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 a tab of their own under Settings, 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']);
```
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).
- **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`).
- Redirects are `Aurora\Models\Redirect` (`from_path`, and `page_id`, `asset_id` or `to_url`, with `status_code`); 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; draw it with svg(SiteIcon::name($data['icon'] ?? null) ?? 'hero-o-sparkles', 'size-6'), style: 'm' for the small set), 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 a tab of their own under Settings, 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']);
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).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).Aurora\Models\Redirect (from_path, and page_id, asset_id or to_url, with status_code); 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?