Search by

jiannius / atom

jiannius

Atom UI components

Package info

github.com/jiannius/atom

pkg:composer/jiannius/atom

Statistics

Installs: 4 315

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 1

v3.37.2 2026-10-16 01:55 UTC

This package is auto-updated.

Last update: 2026-10-16 15:44:47 UTC


README

A Laravel UI component library built on Tailwind + Alpine + Livewire 4. Atom ships ~50 anonymous Blade components, a Livewire trait, an atom() runtime singleton for dispatching modals/toasts/alerts/confirms from PHP, a set of Laravel macros, and a pre-built JS/CSS bundle that the package serves itself — there is nothing to npm install in your host app.

  • Composer: jiannius/atom
  • Namespace: Jiannius\Atom\
  • Custom Blade syntax: <atom:button>, <atom:input.text>, <atom:button.group> …

Requirements

Dependency Version
PHP ^8.3
Laravel (illuminate/support) ^13.0
Livewire ^4.0
Intervention Image ^3.0

Installation

composer require jiannius/atom

The service provider (Jiannius\Atom\AtomServiceProvider) auto-registers via Laravel's package discovery. It will:

  • Mount its routes under /atom/* (asset serving + action endpoint).
  • Register all components under the atom view namespace.
  • Install the <atom:...> Blade tag compiler.
  • Swap Laravel's Date facade for Jiannius\Atom\Services\Carbon.
  • Mix macros onto Eloquent\Builder, Query\Builder, ComponentAttributeBag, Request, Str, Stringable, and Arr.
  • Register the artisan command atom:purge-editor-images.

No publishing step is required to get started.

Page boilerplate

Use <atom:html> once at the top of any full-page Blade view. It writes the <html>, <head>, meta tags, Tailwind base, atom's bundled CSS/JS, and your host app's Vite entry.

<atom:html
    title="My Awesome App"
    description="Hello world"
    :fonts="'inter'"
    :vite="['resources/css/app.css', 'resources/js/app.js']"
    :gtm="config('services.gtm.id')"
    dark>
    <livewire:layout.sidebar>
        {{ $slot }}
    </livewire:layout.sidebar>
</atom:html>

Useful <atom:html> props (all optional):

Prop Purpose
title <title> and OG tag (falls back to config('page.title')).
description Meta description / OG description.
image OG image (defaults to storage/img/logo.png).
canonical Canonical URL.
hreflang Array of locale alternates.
jsonld Raw JSON-LD payload.
gtm / ga / fbp Tracking IDs for GTM, GA, Facebook Pixel.
fonts Google Font family to preload (default inter).
dark Opts the page into dark mode (off by default, on every layout too). Adds class="dark" to <html> and emits the window.darkmode() bootstrap, which then follows the stored preference or, with none, the visitor's prefers-color-scheme. Required for <atom:darkmode-toggle> to work.
styles Additional stylesheet URLs.
scripts Additional script URLs.
editor Loads the editor CSS chunk (for pages using <atom:editor>).
vite Vite entries from your host app.
noindex Sets robots to noindex,nofollow.

The package's own JS/CSS bundle is served from /atom/{file} (immutable, hashed via dist/manifest.json); you do not need to add it to your Vite config.

Component directory

With the package installed and APP_ENV=local, visit /atom/docs in your app for a browsable directory of every component: live previews, copyable code snippets, auto-generated prop tables, and searchable icon/logo galleries. The routes are not registered outside the local environment.

Service provider entry points (reference)

If something feels magic, the answer is almost always in src/AtomServiceProvider.php (boot order: routes → migrations → translations → views → components → tag compiler → date facade swap → macros → asset routes → /atom/action/{name} POST endpoint).

The <atom:...> tag syntax

src/Services/TagCompiler.php rewrites tags before Blade compiles:

You write Resolves to File
<atom:button>...</atom:button> <x-atom::button>...</x-atom::button> components/button/index.blade.php
<atom:icon.check/> <x-atom::icon.check/> components/icon/check.blade.php
<atom:button.group>... <x-atom::button.group>... components/button/group.blade.php
<atom:input.text/> <x-atom::input.text/> components/input/text.blade.php

Dot paths map to subdirectories. The classic <x-atom::...> form still works — <atom:...> is preferred for terseness.

Quickstart example

A typical Livewire 4 form using Atom:

// app/Livewire/Customers/Create.php
namespace App\Livewire\Customers;

use Jiannius\Atom\Traits\AtomComponent;
use Livewire\Component;

class Create extends Component
{
    use AtomComponent;

    public $name;
    public $email;
    public $bio;

    public function breadcrumbs($crumbs)
    {
        return $crumbs
            ->home('Dashboard', route('home'))
            ->push('Customers', route('customers.index'))
            ->push('New Customer');
    }

    public function save()
    {
        $this->validate([
            'name' => 'required',
            'email' => 'required|email',
        ]);

        // ... persist

        $this->toast('Customer created', variant: 'success');
        return redirect()->route('customers.index');
    }

    public function render()
    {
        return view('livewire.customers.create');
    }
}
{{-- resources/views/livewire/customers/create.blade.php --}}
<div>
    @persist('breadcrumbs')
        <atom:breadcrumbs/>
    @endpersist

    <atom:form wire:submit="save">
        <atom:input label="Name" wire:model="name" required/>
        <atom:input type="email" label="Email" wire:model="email" required/>
        <atom:editor label="Bio" wire:model="bio"/>

        <atom:button.group>
            <atom:button href="{{ route('customers.index') }}">Cancel</atom:button>
            <atom:button type="submit">Save</atom:button>
        </atom:button.group>
    </atom:form>
</div>

Helpers

The atom() singleton

app('atom') (aliased) is a single entry point for runtime UI dispatch. Inside a Livewire component, prefer the trait methods ($this->toast(...)) — they delegate here but are shorter. Use app('atom')->... from controllers, jobs, or anywhere outside the component class.

app('atom')->modal('confirm-delete')->show();
app('atom')->modal('details')->slide('right');   // slide-over
app('atom')->modal('details')->close();

app('atom')->toast('Saved.', variant: 'success', delay: 4000);

app('atom')->alert(
    heading: 'Heads up',
    message: 'Your subscription expires tomorrow.',
    variant: 'warning',
    button: 'Got it',
);

app('atom')->confirm(
    heading: 'Delete customer?',
    message: 'This cannot be undone.',
    buttonConfirm: 'Delete',
    password: true,                       // require password re-entry
    onAccepted: 'reallyDelete',           // calls $wire.reallyDelete()
    onRejected: 'cancelDelete',
);

app('atom')->action('Foo.Bar', ['method' => 'doThing', ...$params]);

app('atom')->mail(
    to: $user->email,
    subject: 'Welcome',
    content: '<p>Hello!</p>',
    cta: ['label' => 'Open dashboard', 'url' => route('home')],
    queue: true,
);

$breadcrumbs = app('atom')->breadcrumbs()
    ->home('Home', '/')
    ->push('Customers', route('customers.index'))
    ->push('Edit')
    ->build();

app('atom')->asset()->version('atom.js');     // → /atom/atom-{hash}.js
app('atom')->sitemap();
app('atom')->broadcast();

All heading, subheading, and message strings are auto-passed through t() for translation.

Global functions (src/Helpers.php)

Function Purpose
t($key, $count = 1, $params = []) Translation shim. Number → trans_choice, array → __($key, $array), scalar → __($key, $params). Used internally by every component.
num($value) Wraps Laravel's Number helper. Adds ->currency($iso, $rounding, $bracket, $abbreviate, $maxPrecision, $roundingMode) (2dp, rounded half-up; $roundingMode is half-up, half-even or half-down) and ->filesize($precision). All other Number::* methods proxy through.
carbon(...$args) Returns a Jiannius\Atom\Services\Carbon instance.
js($value) Alias for Js::from().
is_enum($value) True for UnitEnum / BackedEnum.
is_using_trait($class, $trait) True if $class (recursively) uses $trait.
safe_url($url) The URL unchanged if it is http, https, mailto, tel, sms or scheme-less, otherwise null. Reads the URL the way a browser does (entities decoded, tabs and newlines dropped, leading control characters stripped, scheme lowercased). Makes a URL scheme-safe and nothing more: //evil.com is allowed, so a redirect built from user input must also check the host. Every atom component that takes an href uses it.

Examples:

t('Welcome :name', ['name' => $user->name]);
t('item.count', 5);                       // → trans_choice
num(1234.5)->currency('USD');             // → "USD 1,234.50"
num(2048)->filesize();                    // → "2 MB"
num(1500000)->currency('USD', abbreviate: true);  // → "USD 2M"
num(0.065)->currency(maxPrecision: 6);    // → "0.065" (at least 2 decimals, at most 6, half-up; default stays 2dp)

currency() rounds half-up (away from zero) by default, so 10.185 is 10.19; before this change it rounded half-even (10.18). Pass roundingMode: 'half-even' (or 'half-down') for another mode, in any case. roundingMode does not affect the $rounding 0.05 step or $abbreviate. The input is a float: 175 * 9.825 is 1719.3749999999998 and prints 1,719.37, so pass an exact value when a sum lands on a tie. In JavaScript, (0.065).currency(symbol, round, maxPrecision) takes the same maxPrecision (a number or numeric string, clamped 2 to 100; null, NaN or blank mean unset); without it the JS default allows up to 3 decimals, so it is not the same as the PHP default.

The AtomComponent Livewire trait

use Jiannius\Atom\Traits\AtomComponent; on any Livewire component to get:

  • WithPagination + WithFileUploads automatically.
  • Reserved state buckets:
    • $_breadcrumbs — auto-populated from your optional breadcrumbs($crumbs) method.
    • $_table — sort, checkboxes, select-all, max rows, show-trashed (consumed by <atom:table> — see Table selection).
    • $_editor.images — temporary upload URLs for the rich text editor.
  • Short methods that delegate to app('atom'):
$this->modal('name-of-modal')->show();
$this->toast('Saved!');
$this->alert(message: 'Done.');
$this->confirm(message: 'Sure?', onAccepted: 'doIt');
$this->action('Foo.Bar', $params);
$this->wirekey('row', $id);     // stable md5 key for wire:key

Names the trait occupies

A trait method loses to a method of the same name on the class itself — with no error. So a component that defines one of these shadows atom's version. Whether that matters depends entirely on who calls the name, and the two halves are not equally risky.

Safe to shadow — nothing in atom ever calls these on your component. They're sugar that delegates to app('atom'), there for you to call and no one else. Define your own toast() and you get yours; you'll know, because you wrote it.

modal, command, toast, alert, confirm, action, wirekey, verifyRecaptcha

action is protected (see Upgrading to 3.25) — shadow it if you like, but define yours protected too. A public function action() on your component is callable from the browser as $wire.action(...), which is the exposure 3.25 removed.

Silent if shadowed — atom or Livewire invokes these by name, so redefining one kills the feature behind it with nothing to show for it. All of them are prefixed for exactly this reason; the prefix is the protection.

Invoked by Breaks
$_breadcrumbs, $_table, $_editor, $_recaptcha the toTable() macro and the blades sort, pagination, checkboxes
mountAtomComponent, updatedAtomComponent, updatingAtomComponent Livewire, by convention breadcrumbs, editor uploads, trashed-toggle clear, the raw: table-sort signature gate
resetTableCheckboxes, selectAllTableMatching, toggleTableShowSelected, clearTableSelectAll atom's markup, by name the checked-bar buttons no-op
tableSelection, tableSelectionQuery, tableRowsQuery, getTableCheckboxes, isTableSelectAll, isTableShowSelected, isTableShowTrashed each other, and your own items() bulk actions target the wrong rows
$paginators, getPage, gotoPage, nextPage, previousPage, resetPage, setPage, queryStringHandlesPagination Livewire's WithPagination pagination
_startUpload, _finishUpload, _removeUpload, _uploadErrored Livewire's WithFileUploads file uploads

Yours to define: breadcrumbs (optional), tableQuery (required for tableSelection()), tableSelectionQuery (override for sticky selection).

Nothing else is reserved. The computed property feeding <atom:table :paginate="..."> is yours to name: atom neither defines nor looks for items, rows, records, data, or anything like them. tests/Feature/AtomComponentSurfaceTest.php pins this list, so it can't drift from the trait without a failing test.

The Enum trait

use Jiannius\Atom\Traits\Enum; on backed enums to get the convention used by Atom's status badges and selects:

enum OrderStatus: string {
    use \Jiannius\Atom\Traits\Enum;

    case NEW = 'new';
    case PAID = 'paid';
    case CANCELLED = 'cancelled';
}

OrderStatus::all();              // collection of cases (filters out TRASHED)
OrderStatus::get('paid');        // → OrderStatus::PAID
OrderStatus::PAID->label();      // → "Paid" (headline of value)
OrderStatus::PAID->color();      // → "green" (sensible mapping by value)
OrderStatus::PAID->toArray();    // → ['value' => ..., 'label' => ..., 'color' => ...]
OrderStatus::PAID->is('paid', OrderStatus::NEW);

Macros

Eloquent / Query Builder (src/Macros/Builder.php)

User::query()->whereDateBetween('created_at', '2025-01-01 to 2025-12-31');
$q->toPage(2, 50);                       // paginate to page 2, 50 per page
$q->toTable();                           // paginate using $_table state from <atom:table>
$q->filter(['search' => 'tj', 'status:!=' => 'archived']);
$q->breakdown($diff, $start);            // group-by year/month/day for charts
$q->randomCode(8, 'code');               // unique random code
User::query()->tableColumns();           // cached SHOW COLUMNS
User::query()->tableHasColumn('email');
User::query()->tableColumnType('created_at');

filter() knows about: named scopes (search, byFoo), enum casts, JSON columns, date columns, and the key:operator syntax ('price:>=' => 100).

filter() reads keys and values as untrusted input, but it is not an allow-list. What it refuses: a key that is not a plain column name (optionally :operator, or column.path on a JSON column), a column listed in the model's $hidden or, when the model sets $visible, any column not listed there ($visible is a serialisation list, so one that omits created_at, status or a foreign key silently drops filters on them), an operator other than =, !=, <>, <, <=, >, >=, like and not like, and a value shape that makes no sense for the column (it is skipped, never an error). Date columns are filtered through quoted whereDate(). On a plain column a list value means "one of" (= or no operator) or "none of" (!=, <>); a list with <, <=, >, >=, like is skipped, and so is a date object with like. On a JSON column a list means "contains all of" (= or no operator) or "does not contain all of" (!=, <>). It reads the model's $hidden and $visible properties only: not makeHidden() / makeVisible() on an instance, and not sensitive columns you did not list. What it does not refuse: any other column of the table, and any named scope, which receives the value as sent (strings, arrays, dates). A client that can replace a public $filters (Livewire allows it) can therefore filter on every column the model does not hide, and call every scope. When that matters, either use WithUrlFilters, whose declared keys are such an allow-list enforced on every update, or pass filter() only the keys you allow, e.g. filter(Arr::only($this->filters, ['search', 'status', 'dates'])), and keep scopes safe to call with arbitrary input. Don't mark a $filters that atom's table controls bind to (wire:model="filters.search") as #[Locked]: Livewire then refuses every update from them, so search, filter selects and chips stop working. #[Locked] only fits a $filters the server sets and no wire:model touches.

Request (src/Macros/Request.php)

request()->portal();                     // → 'auth' | 'admin' | 'app' | etc., derived from route name
request()->portal('admin');              // → boolean
request()->subdomain();                  // → "client" from "client.app.test"
request()->hostWithoutSubdomain();
request()->isLivewireRequest();

Str / Stringable (src/Macros/Str.php)

str('foo.bar')->namespace();             // → "Foo\Bar"
str('App\Models\User')->dotpath();       // → "App.Models.User"
str('3 months')->interval();             // → "Quarterly"
str()->initials('Tan Joon Long');        // → "TJ"

Arr (src/Macros/Arr.php)

Arr::pick(['xs' => false, 'sm' => true, 'lg' => false]);  // → "sm"

ComponentAttributeBag (src/Macros/ComponentAttributeBag.php)

Used inside component templates:

{{-- inside components/button/index.blade.php style code --}}
$attributes->size('md')                  {{-- → "xs"|"sm"|"md"|"lg"|... from `sm`/`md`/`lg` modifiers or size= --}}
$attributes->modifier()                  {{-- → "lazy", "live", etc. from wire:model.X --}}
$attributes->modifier('live')            {{-- → boolean --}}
$attributes->field()                     {{-- → field name from field=, for=, or wire:model --}}
$attributes->hasLike('wire:click*')
$attributes->getLike('x-on:click*')
$attributes->getAny('alt', 'title', 'aria-label')
$attributes->classes()->add('foo')->add($condition && 'bar')
$attributes->styles()->add('width', '100px')

Component catalog

All components live in components/. Open components/<name>/index.blade.php (or components/<name>.blade.php) to see the canonical @props([...]) list. The tables below list the most-used props per component.

Form inputs

Tag Notable props / subcomponents
<atom:input> name, type (text, email, password, number, tel, color), label, caption, prefix, suffix, required, error. Subs: <atom:input.text>, <atom:input.email>, <atom:input.tel>, <atom:input.color>, <atom:input.field>, <atom:input.prefix>.
<atom:textarea> name, label, caption, rows (default 3), autoresize, variant="transparent".
<atom:select> options — an array for a static list, or a string naming a GetOptions set to fetch remotely. Plus name (field name, not the option set), label, caption, variant (native (default), listbox, filter), required, error, prefix, suffix, inline; filters, multiple, searchable, clearable on listbox; table-filter on native/listbox registers the field as a chip in <atom:table.filters> (key = wire:model, chip name = label). Children: <atom:select.option>, <atom:select.group>.
<atom:checkbox> name, label, caption, align (start, center, end). Group with <atom:checkbox.group>.
<atom:radio> Same as checkbox. Group with <atom:radio.group>.
<atom:toggle> name, label, caption. Group with <atom:toggle.group>.
<atom:slider> Range slider on a native <input type=range>. name, label, caption, min (0), max (100), step (1), value, bubble (value shown on interaction), labels (min/max at track ends), required, disabled. wire:model binds via x-modelable.
<atom:rating> Star rating input + display. name, label, caption, count (5), value, half (half-step selection), readonly (display a fixed/average value), clearable (re-click resets to 0), icon (swap the star for any atom icon). wire:model binds via x-modelable.
<atom:date-picker> name, variant (date, range, calendar), label, caption, inline, prefix, suffix. Subs: <atom:date-picker.date>, <atom:date-picker.range>, <atom:date-picker.calendar>. The input also takes a typed date, committed on blur or Enter: the display format (01 Oct 2024), day-first numerics (01/10/2024) or ISO; a range as from - to or from to to. An entry that does not parse is put back, never sent.
<atom:time-picker> name, label, caption, invalid, inline.
<atom:uploader> label (default Upload), variant, size. Drop variant: <atom:uploader.dropzone>.
<atom:editor> Tiptap rich text. name, label, caption, readonly, autofocus, variant="transparent", placeholder, toolbar, mention. Many sub-buttons under <atom:editor.button.*> and contextual menus under <atom:editor.menu.*>. Requires <atom:html editor> on the page.

Buttons & links

Tag Notable props
<atom:button> type (submit, delete), variant (primary, danger, accent, ghost, link, facebook, google, linkedin, whatsapp, telegram), size (xs, sm, md, lg), block, href, icon, iconSuffix, inverted, newtab. Wraps wire:click, dispatches confirmed for type="delete" (auto-confirmed → $wire.delete()).
<atom:button.group> Layout helper for adjacent buttons.
<atom:link> href, icon, iconSuffix, variant="accent", newtab, rel.

An href is only followed if it is http, https, mailto, tel, sms or scheme-less; see Upgrading to 3.29.13.

Display & typography

Tag Notable props
<atom:heading> size (xs, sm, default, lg, xl, or <n>px) — each size sets both a font size and a weight. level (1..6) renders a real <hN> instead of the default <div>; see Heading levels below.
<atom:subheading> Same as heading, smaller / muted.
<atom:caption> Small muted text.
<atom:label> icon, align.
<atom:kbd> Keyboard-key caps. keys (space/+-separated, maps cmd→⌘, shift→⇧, alt→⌥, ctrl→⌃, enter, esc, arrows, …); or a single cap from the slot: <atom:kbd>Esc</atom:kbd>.
<atom:avatar> src, name, initial, square (default true), size (xs..xl). Stack: <atom:avatar.group>.
<atom:badge> status (enum-aware), size (xs, default, lg), icon, color, label. Group: <atom:badge.group>.
<atom:card> inset, subtle, divided, variant (stats, chart), heading, data, indicator, trend, color.
<atom:chart> Standalone ApexCharts chart. type (bar, area, trend), data (bar/area: label/value/tooltip rows; trend: plain number array), color (red/green/orange/gray or #hex), max (['value'=>, 'label'=>] goal line), min. Default height h-64 (h-16 for trend), override with a h-* class.
<atom:callout> icon, heading, content, variant (info, success, warning, danger, error), closeable.
<atom:skeleton> Animated loading block.
<atom:placeholder-bar> size="100%x20px".
<atom:empty> icon (default inbox), size, subtle, heading, subheading.
<atom:profile> name, avatar, email, size.
<atom:icon.*> 200+ icons. Examples: <atom:icon.check/>, <atom:icon.arrow-left/>, <atom:icon.delete/>. Browse components/icon/.
<atom:logo.*> Payment / brand marks: apple-pay, fpx, google-pay, ipay88, master, senangpay, stripe, tng, visa.

Heading levels

<atom:heading> renders a <div> unless you pass level, because most headings in an app UI are card titles, stat labels and table captions — visual, not structural. size and level are deliberately independent: size is how big it looks, level is where it sits in the document outline.

<atom:layouts.sidebar> supplies the page's <h1> itself, from its title prop — visually hidden, since the visible title comes from <atom:breadcrumbs>, which is a nav landmark and emits no heading element. So you do not need an <h1> in the page body; start at level="2".

Pass level where a section genuinely nests, and leave it off elsewhere:

<atom:layouts.sidebar title="Checklists">   {{-- gives the page its <h1> --}}
    <atom:heading size="lg" level="2">Vehicle details</atom:heading>
    <atom:heading size="default" level="3">Tyres</atom:heading>

    <atom:card>
        <atom:heading size="lg">Revenue</atom:heading>   {{-- a card title: no level --}}
    </atom:card>
</atom:layouts.sidebar>

A suggested mapping when a heading is structural: xl → level="1" (only outside the sidebar layout), lg → 2, default → 3, sm/xs → 4.

Feedback & overlays

Tag Notable props
<atom:modal> name (required for atom()->modal($name) to find it), inset, dismissible, closeable. Trigger via <atom:modal.trigger name="..."> or $this->modal('name')->show() from PHP.
<atom:alert> Window-bound. Triggered by atom()->alert(...). Config keys: heading, subheading, message, variant, button, onDismissed.
<atom:toast> Window-bound. Triggered by atom()->toast(...). Config keys: message, variant (success, warning, danger), delay (default 3000), position (top, bottom, center), align.
<atom:confirm> Window-bound. Triggered by atom()->confirm(...). Supports password, passphrase, optional reason field. Wires onAccepted / onRejected to Livewire methods.
<atom:tooltip> interactive, position (top, bottom, left, right), align (start, center, end), content, kbd, toggleable.
<atom:dropdown> position (bottom, top), align (start, end), locked.
<atom:context-menu> Right-click menu opened at the cursor. locked. Default slot = the right-clickable target; <x-slot:menu> holds <atom:menu.item>s. Reuses <atom:menu>.
<atom:lightbox> Image lightbox; click any <img> inside to zoom.

The four window-level overlays (alert, toast, confirm) are usually dropped once in your root layout — drop them in <atom:layouts.sidebar> or near <atom:html> and dispatch from anywhere.

Layout & navigation

Tag Notable props
<atom:form> inset. Wraps form, handles auto loading state on submit.
<atom:form.modal> name, cols (auto, 1, 2, 3), submit (Save label), dismissible, escapable, closeable. Modal + form + sticky bar: Save on the left, and on the right the optional named slots actions (anything that should stay visible while the form scrolls, e.g. a running total) and delete, in that order. With only delete, or neither, the markup is unchanged.
<atom:table> empty, paginate, maxRows (array of row options), skeleton, trashed, selectAll, stickySelection. Slots: columns, rows, header, checked (bulk-action bar), footer. Children: <atom:table.column>, <atom:table.row>, <atom:table.cell>, <atom:table.checkbox>, <atom:table.search>, <atom:table.filters>, <atom:table.trashed>, <atom:table.actions>, <atom:table.pagination>. Driven by $_table state on the Livewire component. See Table selection.
<atom:tabs> tabs (array), size (sm), variant (button, border). Child: <atom:tabs.item>.
<atom:list> heading, scrollable (default true). Child: <atom:list.item>.
<atom:menu> popover. Child: <atom:menu.item>.
<atom:navlist> Sidebar nav container. Children: <atom:navlist.item>, <atom:navlist.group>, <atom:navlist.badge>.
<atom:navlist.group> heading, expandable, expanded (default true), hiddenIfEmpty (default true), persistKey. See Remembering collapsed groups.
<atom:breadcrumbs> heading (default true). Reads $_breadcrumbs populated by your breadcrumbs() method. Wrap it in @persist('breadcrumbs') so the trail survives wire:navigate: without it each page starts a fresh trail from its own declared crumbs, so pushed crumbs don't accumulate and a crumb (or Back) returns to the bare URL, without the query string (filters, page) you left it with.
<atom:calendar> Month, week and day calendar with a toolbar. wire:model binds the anchor date (Y-m-d); with none it is static. Props: items (an array, or fn ($from, $to) => ... for the visible window), views, view, week-start, weekend, max, time-format, date-format, selectable, draggable, resizable, slot-duration, scroll-time, now-indicator, month, name. Events: calendar-select, calendar-item-move, calendar-item-resize, calendar-item-click. See Calendar.
<atom:separator> align (left, center, right). Slot becomes the label.
<atom:layouts.auth> Centered auth layout (login, register, forgot password). Props: title, noindex, dark, vite.
<atom:layouts.sidebar> App layout with sidebar + top bar. Props: title, noindex, dark, editor, styles, scripts, vite. Pass dark to get the darkmode bootstrap and the header's switcher.

Remembering collapsed groups

An expandable group's open state lives in Alpine, so it resets on every page load. Give it a persist-key and it's remembered in localStorage instead:

<atom:navlist.group heading="Purchase" expandable persist-key="nav.purchase">
prop default behaviour
persistKey null When null, behaves exactly as before — no storage read, no write.

expanded is only the starting state. A stored value always wins, so a group rendered :expanded="false" that the user has expanded stays open across reloads. That precedence is the point of the feature, and the answer to "why is my :expanded="false" ignored?"

Three things worth knowing:

  • The key is namespaced under atom:navlist-group:, so passing "sidebar" can't collide with your app's own localStorage entries.
  • Pass a key that's stable across pages — never one derived from the current route, or the group forgets itself the moment the user navigates.
  • Two groups sharing a key sync. Not guarded, because that's usually what you want when the same nav renders in more than one layout.

Storage access is wrapped both ways: hardened browsers and Safari private mode throw on localStorage, and an uncaught throw would take the Alpine component down and stop the group toggling at all. It degrades to today's behaviour instead. Persistence is per-browser only — there's no server-side or cross-device state.

Miscellaneous

Tag Notable props
<atom:copy> value — copy-to-clipboard button.
<atom:darkmode-toggle> Dark-mode switcher. Needs the page to pass dark to <atom:html> or its layout — without it window.darkmode() is never defined. The sidebar layout renders its own toggle when dark is set.
<atom:dd> Definition list. Child: <atom:dd.group>.
<atom:embed> src, icon, file — embeds image / video / YouTube / file preview.
<atom:error> Plain error message slot.
<atom:html> Page boilerplate (see Page boilerplate).
<atom:sharer> sites (array), url, title — social share buttons. atom does not bundle sharer.js: load it on the page (<script src="https://cdn.jsdelivr.net/npm/sharer.js@0.5.4/sharer.js"></script>) or the share buttons do nothing (the copy-link button works without it). The default sites (facebook, twitter-x, linkedin, whatsapp, telegram, email) were checked against sharer.js 0.5.4; a site needs an atom icon of the same name.
<atom:whatsapp> number, text — floating WhatsApp button.

Table selection

Add checkbox to a header column and :checkbox="$item->id" to the matching cell:

<atom:table.column checkbox />
...
<atom:table.cell :checkbox="$item->id" />

Ticked ids collect in $_table.checkboxes and survive pagination and sorting, so a selection can span pages. The header checkbox selects or deselects the current page only. Bulk-action buttons go in the checked slot — that bar replaces the table header while anything is selected and shows the running count.

Cross-page "Select all N" — :select-all

<atom:table :paginate="$this->items" :select-all>

Once a subset is ticked, a Select all N button appears. It sets a $_table.select_all flag rather than materialising an id list, so it costs the same for 20 rows or 200,000. Ticking an individual row or the header exits the mode.

Requires a tableQuery() on the component — the full scoped and filtered query ($this->filters is client-controlled; see what filter() does and does not refuse):

public function tableQuery()
{
    return Invoice::query()->filter($this->filters);
}

#[Computed]
public function items()
{
    return $this->tableQuery()->toTable();
}

Bulk actions then run off tableSelection(), which covers both modes:

public function deleteSelected(): void
{
    $this->tableSelection()->delete();
    $this->resetTableCheckboxes();
}

Selection that survives a filter — :sticky-selection

By default a filter change, a search, or the trashed toggle clears the selection — the ticked rows may no longer be on screen, and a bulk action over invisible rows is a nasty surprise. Pass :sticky-selection for the opposite trade: keep the ids so a user can build one batch across several searches (tick 2, search, tick 1 more, act on all 3).

<atom:table :paginate="$this->items" :sticky-selection>

select_all still drops on a filter change — it means "everything matching this query", which stops being true the moment the query changes. The trashed toggle still clears everything, since live and soft-deleted rows aren't one result set. A persistent Clear selection appears in the checked bar, because part of the selection is off-screen and the user can't untick what they can't see.

The header slot also stays on screen instead of being swapped out for the checked bar — the two stack. Searching while holding a selection is the whole flow, and a table that hides its own search box the moment you tick a row can't support it.

Reviewing the batch — "Show selected"

A search hides part of the selection, so the checked bar carries a Show selected toggle that lists the selection instead of the filtered rows, search ignored. Flipping back restores the search results. Render the rows from tableRowsQuery() to wire it up:

#[Computed]
public function items()          // your name — atom never defines or looks for it
{
    return $this->tableRowsQuery()->toTable();   // the selection when the toggle is on
}

The computed property is yours to name; <atom:table :paginate="$this->items"> is the only thing that has to agree with it. What atom owns is listed under Names the trait occupies.

Ticked rows stay unticked-able while the toggle is on, which is how a user prunes a batch they can no longer find by searching. The flag clears with the selection, and falls back to the filtered rows if the last row is unticked while it's on — otherwise the table would empty out and take its own toggle with it.

Note this is a filter to the selection, not a union with it: the search keeps returning what actually matches, so a 500-row batch never floods a result set and the paginator total stays honest.

This prop requires tableSelectionQuery() — the scoped but unfiltered base the checked ids resolve against:

public function tableSelectionQuery()
{
    return Invoice::query();                                      // scoped, no filters
}

public function tableQuery()
{
    return $this->tableSelectionQuery()->filter($this->filters);  // + the live filters
}

Without it, ids ticked under an earlier filter get silently intersected away by the current one: the bar reads 3 selected and the delete hits 1. It defaults to tableQuery(), so a table that doesn't opt in is unaffected. It deliberately does not default to a bare newQuery() on the model — that would make the prop work with no override, but stays tenant-safe only where scoping is a global scope, and the consuming app is the only party that can say what "unfiltered but still scoped" means.

Clearing from a custom filter control

<atom:table.search>, <atom:select.filter> and <atom:date-picker.range> already dispatch the event. A hand-rolled control opts in the same way:

<input x-on:input="$dispatch('table-filter:changed')" />

Filters in the URL — WithUrlFilters

A user filters a listing, opens a record, comes back through the breadcrumb and finds every filter gone: the filters only ever lived in Livewire state. ?page= and ?tab= already survive the trip because they are in the URL, and the breadcrumb already saves the listing's full URL. WithUrlFilters puts the filters there too (?search=ap&status[0]=fresh&region=SG).

It is opt-in: a component that does not use it declares no query string and is unchanged. It is not automatic because nested components (pickers, footprints, report panels) often carry a $filters of their own and would fight over ?status=.

use Jiannius\Atom\Traits\AtomComponent;
use Jiannius\Atom\Traits\WithUrlFilters;

class Claims extends Component
{
    use AtomComponent, WithUrlFilters;

    public array $filters = ['search' => '', 'status' => [], 'region' => 'MY', 'tenant_id' => null];

    // what may be filtered, and its default. Static: it is read before mount()
    protected function urlFilters(): array
    {
        return ['search' => '', 'status' => [], 'region' => 'MY'];
    }

    // internal state that stays in $filters: not in the URL, and the browser may not set it
    protected function urlFiltersExcept(): array
    {
        return ['tenant_id'];
    }

    // optional: app-specific checks (a date that parses, a status that exists)
    protected function isUrlFilterValid(string $key, mixed $value): bool
    {
        return $key !== 'region' || in_array($value, ['MY', 'SG', ''], true);
    }

    public function mount(): void
    {
        $this->filters['tenant_id'] = auth()->user()->tenant_id;
    }

    public function tableQuery()
    {
        return Claim::query()->filter($this->filters);
    }
}

Each declared key becomes one flat query key through Livewire's dotted queryString entries (filters.status as status), with history: false (replace, so typing does not stack history entries) and the default as except (a filter that returns to its default leaves the URL). Two optional methods: urlFiltersProperty() (default 'filters') and urlFiltersPrefix() (default '', for a page with two listings: 'claims_' gives ?claims_status=). A key must be a plain identifier (letters, digits, underscore: it becomes a query key, so no ., no col:operator) or the component throws at mount. Filter on an operator through a named scope instead. A key (with its prefix) that another query-string entry already uses also throws at mount: page on a component that uses WithPagination (every AtomComponent does), your own queryString, a #[Url] property (a host tab is one of these; a filter named tab or page is fine on a component with neither). A custom paginator page name (->paginate(10, pageName: 'p')) cannot be seen at mount, because Livewire registers it only when the paginator is built; the trait checks at the end of the initial render and throws the same LogicException if a filter key (with its prefix) equals a page name. Without it the two fight over one query key: ?search=ap is read as page "ap" while it also filters, and nextPage() is a TypeError. Give the filter another key or a urlFiltersPrefix(), or the paginator another pageName. The check does not run on a Livewire update request ($paginators is a public property, so a client can write any name into it and it would be a 500), so a page name your component registers only on a later request is not caught. WithoutUrlPagination does not change either rule: it only stops Livewire writing the page to the URL, it still reads it from the query (?search=ap makes the page "ap"), so such a component reserves page and is checked too.

The declaration is an allow-list

The browser controls the URL and, with Livewire, a public $filters; atom's own filter() macro refuses unsafe shapes but still filters any visible column and calls any named scope (see filter()). A link planted in front of another user would otherwise reach all of it. So the declaration is enforced where a value arrives, and the two paths fail differently on purpose:

  • From the URL: dropped silently, at mount (after your own mount()). A key that is not declared or excepted is removed, a value of the wrong shape (a scalar where a list is expected, a keyed array, a nested array) or one isUrlFilterValid() refuses falls back to its default, and the listing opens unfiltered. Livewire writes URL values with data_set(), which fires no updating* hook, so this is the only gate they pass. A stale bookmark or a crafted link should open the listing, not break it. Your own mount() runs before this clean-up, so it sees the raw URL values: do not read $this->filters there to decide anything. Validate in isUrlFilterValid() instead (a throw from it is refused, so Carbon::parse($value) is fine in it). A throw is refused and also logged as a warning (component, key, exception class and file:line, never the message or the value, both of which a visitor chose), at most once an hour per component, key and exception class (deduped through the cache), so a bug in your check (a TypeError) shows up in the logs.
  • From the browser: refused with a 403, on every update (updatingWithUrlFilters), like the signed raw: sort. A $wire.set() of a key nobody declared is a deliberate request for something you never offered. Livewire fires the hook once per dotted path, so filters (the whole array: every key and value is checked), filters.status (one key) and filters.status.0 or deeper (never allowed: an array is only ever replaced whole) are all covered. An excepted key is refused from the browser too, and survives a wholesale $wire.set('filters', {...}). A replacement that leaves a declared key out gets that key's default.
  • On both paths a string must be valid UTF-8 (Livewire cannot serialise the snapshot with an invalid one) and at most urlFilterMaxLength() bytes (default 1000), and a list at most urlFilterMaxItems() items (default 200: a whereIn() with thousands of placeholders fails on some databases). Override either method to change the cap.

This is why the property must not be #[Locked] (the table controls bind wire:model="filters.*", and Livewire refuses every client update to a locked property): the trait is the gate instead. It covers named-scope keys and plain column keys alike, because neither can reach filter() unless declared. It does not replace filter(Arr::only($this->filters, [...])) for a component that does not use the trait, nor the values you declare: a declared status scope still receives whatever shape-valid value arrives, so keep scopes safe for arbitrary input.

Defaults and "All"

  • A multi-select default must be []. Livewire merges a list from the URL into the default by index, so a non-empty default could not be cleared (urlFilters() throws on one).
  • "All" is '', not null, when the default is not null. null is not written to the URL, so a reload would bring the default back; '' is written (?region=) and a reload keeps it. A filter whose default is null or '' needs no such care.
  • Declare a static default once, in urlFilters(); the trait fills it into $filters. If a default is computed in mount() (this month's range), declare null and fill it with ??=, not blank() / empty(): the URL is read before your mount(), and blank() would take a user's explicit "All" ('') for "no choice" and bring the default back. A computed default differs from the declared one, so it is written to the URL on load. Keep a public array $filters default in step with urlFilters(): a key present in the property is never overwritten by the fill.

Breadcrumbs, and what comes back

The crumb and Back return to the URL the breadcrumb saved when the user left, which carries the filters (and ?page=). That only works when the trail outlives wire:navigate: wrap <atom:breadcrumbs /> in @persist('breadcrumbs'), or each page starts a fresh trail from its own declared crumbs and the saved URL is lost (without it a crumb returns to the bare URL). The browser's Back button needs nothing: Livewire's history: false replaces the entry, so the filtered URL is the one in history.

Livewire's JS writes a list as status[0]=a&status[1]=b; a hand-written status[]=a link is read correctly by PHP but rewritten by the browser on load. Generate links with the indexed form.

Sort is not persisted. If it ever is, the URL value must be re-checked on mount with TableSort::isUnsafeFromClient(): updatingAtomComponent() only guards a browser update, and a URL value is not one. The seam is documented on mountWithUrlFilters().

tests/Feature/UrlFiltersTest.php and tests/e2e/url-filters.spec.js (fixture tests/Fixtures/UrlFiltersFixture.php) pin all of the above.

Calendar

<atom:calendar> is a month, week and day calendar with a toolbar (today, previous and next, a date jump, a view switcher). It lays out the items you give it, lets people click, drag-select to create, and move or resize an item with the pointer, and tells your code what they did through events. It saves nothing itself. Live previews: /atom/docs/calendar (local environment).

Quick start (Livewire)

Bind the anchor date with wire:model and pass the items as a closure of the visible window. The method behind the closure must be protected: the view is bound to the component, and a public method would be callable from the browser.

use App\Models\Event;
use Jiannius\Atom\Services\Calendar;
use Jiannius\Atom\Services\Carbon;
use Jiannius\Atom\Traits\AtomComponent;
use Livewire\Component;

class Index extends Component
{
    use AtomComponent;

    /** The anchor: `Y-m-d` or `Y-m`. Empty, or anything unreadable, is the viewer's today. */
    public string $date = '';

    /** Every event that overlaps the window. */
    protected function calendarItems(Carbon $from, Carbon $to): iterable
    {
        return Event::query()
            ->where('starts_at', '<=', $to)
            ->where(fn ($query) => $query
                ->where('ends_at', '>=', $from)
                ->orWhere(fn ($query) => $query->whereNull('ends_at')->where('starts_at', '>=', $from)))
            ->get()
            ->map(fn (Event $event) => [
                'id' => $event->id,
                'title' => $event->title,
                'start' => $event->starts_at,
                'end' => $event->ends_at,
                'all_day' => $event->all_day,
                'color' => $event->color,
            ]);
    }

    public function create(string $start, string $end, bool $allDay): bool
    {
        $this->authorize('create', Event::class);

        Event::create(['title' => 'New event', 'all_day' => $allDay, ...$this->instants($start, $end, $allDay)]);

        return true;
    }

    public function reschedule(int $id, string $start, ?string $end): bool
    {
        $event = Event::findOrFail($id);

        $this->authorize('update', $event);

        $event->update($this->instants($start, $end, $event->all_day));

        return true;
    }

    public function edit(int $id): void
    {
        $this->authorize('update', Event::findOrFail($id));

        /** Open your edit form for this event here. */
    }

    /** What the calendar says is the viewer's wall clock; what is stored is an instant. */
    protected function instants(string $start, ?string $end, bool $allDay): array
    {
        $dayAfter = fn (string $date) => Calendar::fromLocal(Carbon::parse($date)->addDay()->toDateString());

        return [
            'starts_at' => Calendar::fromLocal($start),
            'ends_at' => $end === null ? null : ($allDay ? $dayAfter($end) : Calendar::fromLocal($end)),
        ];
    }
}
<atom:calendar
    wire:model="date"
    :views="['month', 'week', 'day']"
    selectable
    :items="fn ($from, $to) => $this->calendarItems($from, $to)"
    x-on:calendar-select="$event.detail.waitUntil($wire.create($event.detail.start, $event.detail.end, $event.detail.allDay).then((saved) => saved || Promise.reject()))"
    x-on:calendar-item-move="$event.detail.waitUntil($wire.reschedule($event.detail.id, $event.detail.start, $event.detail.end).then((saved) => saved || Promise.reject()))"
    x-on:calendar-item-resize="$event.detail.waitUntil($wire.reschedule($event.detail.id, $event.detail.start, $event.detail.end).then((saved) => saved || Promise.reject()))"
    x-on:calendar-item-click="$wire.edit($event.detail.id)" />

create(), reschedule() and edit() are Livewire actions, so the browser can call them with any arguments: authorise and scope them as you would any action. create() and reschedule() return true once they have saved, and the tag turns anything else into a refusal, see Saving a change. The query asks for ends_at >= $from, a harmless over-fetch of anything that ends exactly at the window's start, so that an item with no length that sits on the window's first instant is not dropped.

How the window and the data flow work

  • The window is always the 6-week month grid that contains the anchor, whatever the view (42 days, whole weeks, from the first day of the week). Week and day are subsets of it, so switching view makes no request.
  • Your items closure is called on every render with the window's first and last moment, $from and $to, as Jiannius\Atom\Services\Carbon instants in the application timezone (config('app.timezone')), $to being the last microsecond of the last day. The browser never sends a range, so it cannot widen your query.
  • Moving the anchor inside the window (previous and next in a week or a day, the date jump, today) makes no request: the bound property is updated in the browser only, and goes to the server with the next request. Leaving the window sets the property and asks the server for the window around it, with a spinner in the toolbar meanwhile.
  • A save that re-renders your component (a normal Livewire action) sends a new payload, and the calendar draws only what changed, so a single created, moved or deleted item needs no extra code. A payload that arrives while someone is dragging, resizing or selecting waits until the gesture ends.
  • If your PHP changes the bound property, the calendar follows it.
  • The bound property's name is printed on the element (data-atom-calendar-model), so the browser can set it; its value is read on the server and is not printed. Two calendars bound to the same property name of the same component need a name each, so their ids differ.
  • Payload size: each render sends every item of the 6-week window to the browser as JSON on the calendar's root element: about 100 KB of JSON for 500 items, and nearly twice that in the HTML, because the attribute is escaped. Keep the items closure's query to $from and $to, and select only the columns an item is built from.
  • The calendar's body is wire:ignored, so a re-render of your component never reaches into it. Anything you put in the default slot (extra toolbar content) is yours and renders with the page as usual.

Props

Prop Default
items [] An iterable of items, or a closure fn (Carbon $from, Carbon $to) => iterable.
views ['month'] Any of month, week, day. More than one shows a switcher.
view first of views The view it opens in.
week-start monday Any weekday name.
weekend ['saturday', 'sunday'] The days that get the weekend tint; pass your own where the weekend is not Saturday and Sunday.
max 3 How many rows a day shows in the month (and in the all-day row of the week and day views) before "+N more". false, 0 or none shows every row.
time-format g:i A Times on items, in the grid and in labels. H:i for 24-hour.
date-format j M Y Dates in labels, such as the name of a day.
selectable off Click or drag over empty space to create (fires calendar-select).
draggable on Items can be moved. An item's own draggable wins.
resizable on Items can be pulled by their end. An item's own resizable wins.
slot-duration 30 Minutes a slot of the time grid is, and what a drag snaps to: 5, 10, 15, 20, 30 or 60.
scroll-time 06:00 The time the week and day views open at.
now-indicator on The red "now" line in the week and day views.
month none The anchor of a calendar with no wire:model.
name none A handle of your own: the ids inside the calendar are built from it.

time-format and date-format take a set of date() tokens: g G h H i a A for a time, d j D l m n M F y Y for a date, plus spaces, digits, punctuation and non-ASCII text (Y年n月j日). There is no escape character, any other ASCII letter is refused, and a format that is refused, or has none of its kind's tokens, falls back to the default. Month, week and day titles are written the way the locale writes them (October 2026, 2026年10月) and are not affected by date-format.

Anything else you put on the tag (class, data-*, x-on:*) goes to the root element. It is a width: 100% container; give it a width with a utility class. The week and day views scroll sideways where a week does not fit (a column is never narrower than --atom-calendar-min-column, 6rem), and --atom-calendar-hour (3.5rem) and --atom-calendar-grid-height (min(70vh, 48rem)) set an hour's height and the grid's height.

Items

An item is an array (or Arrayable):

Key Required
id yes An integer or a non-empty string, unique in the calendar. The browser keys items by string, so 1 and '1' are one item. A repeated id keeps the first item and reports the rest.
title yes Plain text. It is never printed as HTML.
start yes A date or an instant, see below.
end no See below. null is no end.
all_day no Defaults to true for a bare Y-m-d start, false for an instant.
color no A preset name, a name you added, a hex colour, or a backed enum with a color() method.
icon no The name of an Atom icon (check, calendar). Anything that is not one is ignored.
danger no The overdue look: red title, a warning icon, and "Overdue" for screen readers.
href no Makes the item a link. It goes through safe_url(), so a javascript: URL is dropped and the item becomes a button.
draggable, resizable no Override the calendar's props for this item.

Dates. A DateTimeInterface and an ISO string with an offset are exact instants. A string with no offset (2026-10-09 09:30) is read in config('app.timezone'). A bare Y-m-d is a calendar date and is never converted. An all-day item given an instant takes the viewer's date of that instant.

Ends. An all-day item's end is the last day, included (start 2026-10-05, end 2026-10-09 covers five days); given an instant, it covers up to one second before it, so a stored end of local midnight after the last day works. A timed item's end is exclusive. An end that is missing, unreadable or not after start is no end: the item is a short block (a dot, in the month).

A missing id, title or start throws an InvalidArgumentException, because that is a bug in the code that builds the items. A start that cannot be read skips the item and reports it once.

Colours. red, orange, yellow, green, blue, purple, black, zinc and gray are presets, each a tint, an accent edge and a dot that read in light and dark, with the text left neutral. An item with no colour, or a name the calendar does not know, is zinc. A hex colour (#rgb, #rgba, #rrggbb, #rrggbbaa) is used as it is, and its edge is adjusted where it would not show. Add or replace a preset in config/atom.php:

'calendar' => [
    'colors' => [
        'teal' => [
            'tint' => 'bg-teal-100 dark:bg-teal-500/20',
            'accent' => 'border-teal-600 dark:border-teal-400',
            'dot' => 'bg-teal-600 dark:bg-teal-400',
        ],
    ],
],

Names are lowercase letters, digits and dashes; a key you leave out keeps the preset's. Tailwind has to see those class strings, so make sure your build scans the file that holds them.

Dates and timezones

Everything the calendar shows and says is in the viewer's timezone, Jiannius\Atom\Services\Carbon::getLocalTimezone() (the signed-in user's timezone, else the TIMEZONE environment variable, else Asia/Kuala_Lumpur): the same one local() and pretty() use, and not config('app.timezone'). The browser's own zone never matters.

Set the zone where it is read from. TIMEZONE is read with env(), and env() is null once php artisan config:cache has run unless the value is a real environment variable of the server (the web server's or the container's, not only a line in .env). So give each user a timezone, or set TIMEZONE in the server's environment when config is cached: with neither, everyone gets Asia/Kuala_Lumpur. A user whose timezone is empty or is not a zone name PHP knows is shown config('app.timezone') instead, and it is reported once, rather than the calendar throwing.

Convert every date the calendar gives you with Calendar::fromLocal() (Jiannius\Atom\Services\Calendar). Events carry wall-clock strings (2026-10-09T09:30, or 2026-10-09 for days), and fromLocal() turns one into an instant in the application timezone, daylight saving included. It throws InvalidArgumentException for anything that is not Y-m-d or Y-m-dTH:i[:s] with no offset. Do not hand the string to Eloquent as it is, and do not give it a Carbon in another zone: Eloquent writes a Carbon's wall clock without converting it. A bare date (allDay: true) is a date, not an instant: store it as one, or add the day after its end as in the example above.

Building dates for the viewer. Calendar::now() is the current moment as a Carbon in the viewer's timezone, so Calendar::now()->startOfWeek() is the start of the viewer's week and not the server's (the docs demos use it). Calendar::timezone() is the viewer's zone name and Calendar::appTimezone() is config('app.timezone'), the zone fromLocal() returns and the items closure's $from and $to are in. Plain now() is in the application timezone.

Events

The calendar dispatches bubbling events from its root. Listen with x-on: on the tag, on an element around it, or on document. Times are the viewer's wall clock.

Event Detail
calendar-select { start, end, allDay, waitUntil } Click or drag over empty space, on a selectable calendar. Not cancelable. For days (the month, the all-day row) start and end are Y-m-d and end is the last day, included: one clicked day has start === end. For a time (the time grid) they are Y-m-dTH:i and end is exclusive, the first minute after the last slot.
calendar-item-move { id, start, end, allDay, waitUntil } An item was dropped somewhere else, with its length and, in the month, its time of day kept. end follows the same rules, and is null for a timed item that has none. Cancelable.
calendar-item-resize { id, start, end, waitUntil } An item's end was pulled. start stays. Cancelable.
calendar-item-click { id } An item without an href was clicked, or Enter or Space was pressed on it. This is also how a keyboard reaches an item: open your edit form from it.

preventDefault() on a move or a resize says no, and the item goes back at once. A drop that lands where the item already was fires nothing.

Saving a change: use waitUntil

Pass the promise of your save to waitUntil() while you handle the event, as the example above does with $wire.reschedule(...):

x-on:calendar-item-move="$event.detail.waitUntil($wire.reschedule($event.detail.id, $event.detail.start, $event.detail.end).then((saved) => saved || Promise.reject()))"

where reschedule() ends with return true; once it has saved.

  • The item is drawn where it was dropped as soon as the pointer lets go, and the calendar says "Saving..." to screen readers while the promise is pending. A selection shows a "Creating..." placeholder where it was, until the promise settles.
  • If the promise rejects, the item goes back to where it was. A failed validate() does not reject it. Livewire catches a validation error on the server, answers normally, and $wire.method() resolves with null, the same as a method that saved and returned nothing, so the item would stay where it was dropped. Refuse in the tag instead: the method returns true once it has saved, and .then((saved) => saved || Promise.reject()) turns anything else into a rejection. A failed validate() then puts the item back, the errors land in the error bag as usual, and nothing else is shown. A failed request rejects the promise as well, and so does a method that throws or calls abort() (a failed authorize() is a 403), but Livewire then shows its own error modal. Returning does not reject the promise by itself: return false to refuse, true once saved. Any truthy value counts as saved, and the return value is sent to the browser, so return true, not a model.
  • Without waitUntil(), or with a promise that resolves, the item stays where it was dropped until the payload changes, so a failed or refused save leaves it in the wrong place until something else re-renders the calendar with different data. Calling it has to happen while the event is being handled; a call made later is ignored.
  • A payload that arrives while the promise is pending does not move the item. Once it settles, a payload that arrived meanwhile takes over, and the item is where your server has it. With none, the item stays where it was put until the next one.
  • Do not make the method #[Renderless] unless you have thought it through. A renderless response sends no payload, so when its promise resolves the item stays exactly where it was dropped, even if your method changed the time (clamped it, applied a rule), and a created item never appears. A normal action re-renders, and the new payload is what the calendar shows. A move that always saves as dropped can be renderless, but it must still return true: a renderless method that returns nothing resolves undefined, the tag above rejects it, and the item snaps back after a successful save. The same goes for every method the tag calls, renderless or not: with this tag each save method has to return true, or the item is put back even though it was saved (a method that returns nothing relies on a re-render to correct it).

Keyboard, touch and screen readers

  • Month: the grid has one tab stop (today, or the day last focused). Arrow keys move a day or a week, Home and End go to the ends of the week, PageUp and PageDown a month; a key that leaves the window asks the server for the next one, and the focus follows. Enter or Space opens the day's list of items. On a selectable calendar they select an empty day, as a click does. Escape closes the list or gives up a drag, and gives the focus back to where it was.
  • Week and day: each day is a labelled group, and its items are buttons, in the order they start, named with their title, day, time range and colour. A day's header opens that day in the day view.
  • Dragging is pointer only. Moving and resizing have no keyboard form: calendar-item-click is the alternative, so open an edit form with the date fields from it. Creating from the keyboard works in the month; in the time grid, select with the pointer. Escape gives up a gesture in progress.
  • Pointer rules: a press moves more than 5 px before it is a drag, so a click stays a click. On touch, a gesture starts after a half-second press, so a finger that scrolls keeps scrolling. A press with Ctrl, Cmd, Alt or Shift held is not a gesture.
  • Narrow screens: where the calendar is under 600 px wide (a measure of the calendar, not the screen), a month day shows its number and up to four dots instead of titles, and tapping a day with items opens its list (also on a selectable calendar; a drag or a long press still selects). The week and day views scroll sideways with the hour gutter and day headers kept in place; the day view fits a phone best.
  • Titles, day names, buttons and the "+N more" label go through __(); the month, week and day names follow the app locale.
  • Right-to-left layouts are not supported. The grid, the toolbar, the hour gutter and the arrow keys are all laid out and handled left to right, whatever dir says.

Without Livewire

With no wire:model the calendar is static. It shows month (or the current month), lays out the items it is given, and switches views, but has no previous, next, today or date jump. Events still fire, so you can drive it from Alpine. Nothing sends a payload, so a moved item stays where it was dropped.

<atom:calendar month="2026-10" :items="$items" x-on:calendar-item-click="open($event.detail.id)" />

Upgrading from the event-calendar wrapper

The old <atom:calendar> wrapped @event-calendar/core. It is gone, together with that package and its calendar JS chunk and CSS file, and the new component is built into atom.js and atom.css. The tag is the same, nothing else is. A render throws, naming the attribute and what replaces it, when the tag itself carries any of these: the props modes and periods; an x-model (with or without modifiers; wire:model is fine); or an x-on: or @ listener, with any modifier (.window, .prevent...), for calendar-date-clicked, calendar-date-selected, calendar-event-clicked, calendar-event-dropped, calendar-event-resized, calendar-changed or calendar-mounted. Anything that is not on the tag cannot be seen, and does not throw: it stops working without a message. That is a $dispatch('add-calendar-events', ...) (or update- or remove-) from anywhere on the page, a listener you put on window or document in a script, and CSS aimed at the old markup. Search your code for -calendar-events, calendar- and ec-.

Props

Old Now
periods="['month', 'week', 'day']" views. Passing periods throws.
modes="['calendar', 'timeline']" Removed. There is no timeline, resource or list view. Passing modes throws.
name Still a prop, but it only keys the ids inside the calendar. It no longer picks which calendar an event is for.
First day of the week was Sunday week-start defaults to monday: pass week-start="sunday" to keep Sunday.
A day grew to fit its items A day shows max rows (3) and "+N more". :max="false" for the old behaviour.
Every click and drag selected selectable is off by default. Add it to keep creating. Drag and resize stay on.
x-model="date" (an ISO instant) wire:model="date" (Y-m-d), or none. Passing x-model throws. A property that still holds an ISO instant (2026-10-09T10:00:00.000Z) is not a date the calendar reads: it shows today, with no error. The anchor takes Y-m-d or Y-m, or a DateTimeInterface, which a Carbon-typed property is (read as the viewer's date).
Offset-less strings were read, and everything drawn, in the browser's timezone An offset-less string is read in config('app.timezone'), and everything is drawn in the viewer's timezone, Carbon::getLocalTimezone() (the signed-in user's timezone, else TIMEZONE in the environment, else Asia/Kuala_Lumpur), whatever the browser says. A host with neither a timezone on its users nor TIMEZONE set shows everyone Kuala Lumpur time: set one. With config cached, TIMEZONE has to be a real environment variable of the server, since a value that is only in .env is null then. See Dates and timezones.
CSS aimed at .ec-* classes, --ec-* variables or [&_.ec-event] utilities on the tag No longer applies: none of that markup exists. The look comes from the color presets, config('atom.calendar.colors') and the --atom-calendar-* variables above; delete the old rules.

Getting items in. The add-calendar-events, update-calendar-events and remove-calendar-events window events are gone, and so is the calendar-changed event that told you to fetch. The calendar asks your items closure for the window on every render, and a normal re-render after you save is the update, so a #[Renderless] fetch method and the events around it can be deleted. A host that fetched in calendar-changed replaces that with the closure and the overlap query in the Quick start.

Item fields

Old Now
start_at, end_at start, end (an end that is a date is the last day, included)
all_day all_day
bg_color, background_color color: a preset name or a hex colour. An item with none is zinc: the old default was a dark zinc-600 fill in a Tailwind v4 host (red-800 only where a Tailwind v3 build applied the wrapper's [&_.ec-event]:bg-red-800), the new one is the lighter zinc preset chip, a pale tint with a zinc edge and neutral text.
draggable, resizable draggable, resizable
text_color, class, style, meta, editable, resources Removed. Text is always neutral; look the record up by id from the event.
New: icon, danger, href

Events. The old events carried { info, mode, period, calendar }, with JavaScript dates and startUtc strings in info. The new ones carry the details above, as wall-clock strings, and the calendar object is not exposed.

Old Now
calendar-date-clicked, calendar-date-selected calendar-select (a click is a one-day select; needs selectable)
calendar-event-clicked calendar-item-click
calendar-event-dropped calendar-item-move
calendar-event-resized calendar-item-resize
calendar-changed, calendar-mounted Removed

An x-on: or @ listener for any of the old events, on the tag, throws and names its replacement; one on window, document or a wrapping element is not on the tag, so it is silent: search for it.

Every date in an event goes through Calendar::fromLocal() where an info.startUtc was used before; see Dates and timezones. The is-loading class a host could bind is not needed: the toolbar shows its own spinner while it waits for a window.

The AsEditorContent cast

Use this when you want a column to behave as Tiptap rich-text content with automatic image persistence:

use Jiannius\Atom\Casts\AsEditorContent;

class Article extends Model
{
    protected $casts = [
        'body' => AsEditorContent::class,
    ];
}

What it does on save:

  1. Scans the HTML for Livewire temporary preview URLs (/livewire-{hash}/preview-file/...).
  2. Resizes each via Intervention Image (max width 1000, quality 80).
  3. Persists to Storage::disk(env('FILESYSTEM_DISK')) under <configured folder>/editor/.
  4. Rewrites the URLs back into the HTML, serializes the result.

get() lazily unserialize()s, falling back to the raw value if it isn't serialized.

Pair with the scheduled command to clean up images no longer referenced:

php artisan atom:purge-editor-images            # dry-clean (move to editor-purged/)
php artisan atom:purge-editor-images --dry-run  # list what it would delete, change nothing
php artisan atom:purge-editor-images --force    # delete the editor-purged/ backup

A file counts as referenced when its file name (or its percent-encoded form) appears anywhere in a stored editor value, whatever the format around it. The scan splits each form of a value (raw, decoded, percent-decoded) into tokens once, so its cost still follows the size of your data, not the number of files, and it prints a progress line every 1000 rows. A file whose name holds a space, quote, bracket or non-ASCII character (a macOS Screenshot ... .png stored through tiptapStoreImage(), say) is searched for as a plain substring instead, which is slower when there are many such files.

Editor and chat HTML is untrusted

<atom:tiptap.chat> (the chat composer) hands the host HTML: tiptap.getHTML(), in an input event carrying { body, files }. Nothing checks it on the way in. A client that skips the editor and calls $wire.submit(...) (or sets the bound property) with its own string stores whatever it likes, and stored and printed as-is that is stored XSS. atom can't see your storage code, so the rule for a host that keeps chat HTML is:

  • Clean it before you store it: Jiannius\Atom\Tiptap\Content::sanitize().
  • Print it through atom: <atom:tiptap.content :content="$message->body"/> or Content::render($message->body). Both parse the value through the editor's schema and print only what the schema allows.
  • Never print it with x-html, {!! $body !!} or ->html() straight from the column or from the request.
use Jiannius\Atom\Tiptap\Content;

// the chat composer dispatches `input` with { body, files }
public function submit(array $message): void
{
    $raw = $message['body'] ?? '';
    $body = Content::sanitize($raw);

    // '' covers three cases: empty, refused (too big) and failed. Ask which if the user should be told.
    if ($body === '') {
        if (Content::sanitizeRefuses($raw)) {
            $this->addError('body', 'That message is too long.');
        }

        return;
    }

    $this->task->messages()->create(['body' => $body, 'user_id' => auth()->id()]);
}
{{-- reads back through the same schema --}}
<atom:tiptap.content :content="$message->body"/>

This is for HTML, not for the JSON editor. <atom:tiptap> (and the AsTiptapContent cast) store Tiptap JSON, and sanitize() returns HTML. Don't pass JSON editor content through it before storing: that would turn the column into HTML and drop YouTube embeds. JSON is already printed safely by <atom:tiptap.content> / Content::render(), so store it as JSON and print it through those.

Rows stored before you adopt sanitize() stay unclean. sanitize() only cleans what passes through it from now on. A chat message already in your table keeps whatever it was saved with until you render it through <atom:tiptap.content> / Content::render() (which cleans on the way out, so old rows print safely), or backfill the column by running each row through Content::sanitize(). A host that prints stored HTML with x-html is exposed to every unclean row it already holds.

Content::sanitize(mixed $html, int $maxBytes = 131072, int $maxTags = 5000): string parses the value through the same schema and the same hardened extensions as render(), and returns the schema's own serialisation, so nothing of the input's markup survives except what the editor itself can write. It also accepts a Tiptap JSON document (a string or array with a type key) and a Stringable; any other string, '42' and 'null' included, is text (printed in a paragraph, like any loose top-level text).

It returns '' when the input is empty, when it is refused, and when it fails. It refuses (silently, so a client can't flood your logs) HTML over $maxBytes, HTML with more than $maxTags tags (or a document with more than $maxTags nodes and marks), input whose output would have more than $maxTags tags (a document prints up to four tags per node, and an empty table is one node), a document that is not a Tiptap document, a document nested more than 200 levels, HTML that carries tiptap-php's reserved MINIFYHTML placeholder, and the <pre> inputs the parser can't survive (below). Output over four times $maxBytes is refused too, so a small $maxBytes also caps the output at four times that. Only an unexpected failure is reported through Laravel's report(). Content::sanitizeRefuses($html) tells a refusal from an empty message.

The limits exist because parsing cost tracks the tag count, not the byte count: about 3 KB of memory per tag or document node in the worst case, so an input of 65,000 <p>a (260 KB) needs over 200 MB, and 95,000 {"type":"paragraph"} nodes (2 MB of JSON) needs about 290 MB. Measured at the sanitize defaults, the worst shape costs about 16 MB and 0.2 s; a long chat message is a few KB and a few dozen tags. Raise the limits only for a field that holds long documents. The parser also has inputs it takes minutes on or crashes on, which are normalised where real content could have them and refused only where it can't:

  • Long whitespace runs are collapsed, not refused. Outside a <pre>, a run of more than 32 whitespace characters (Unicode spaces included) becomes one space before parsing. HTML collapses whitespace anyway, so nothing a reader sees changes, and content with no such run is passed on byte for byte. Pretty-printed or indented legacy HTML and pasted Word markup render normally (120 KB of spaces used to take 98 s and now takes about 0 s). Inside a <pre> nothing is touched: a code block with 300-space indentation, or 120 KB of spaces, is kept exactly.
  • Unclosed <pre> tags are closed, not refused. A few are left alone; once they would make the parser quadratic (5000 in 130 KB took 8 s), one </pre> is appended, which is what the HTML parser does at the end of the input anyway.
  • Refused: a <pre> block over 500,000 characters (the parser's regex fails past about 1M), a very large number of <pre> blocks in a long input (about 250 code blocks in a 2 MB document), and many <pre tags with no > after them. Real content has none of these.
  • Repaired, not refused: a Tiptap document with an attribute the renderer throws on keeps rendering. A heading level that is not a whole number from 1 to 6 (or that digit as a string) becomes level 1, a list start, a cell colspan or rowspan that is not a whole number is dropped, and a colwidth, a mention label or an attrs of the wrong shape is dropped. Only a document that is not a document at all (wrong type, content not a list, and so on) is refused.
Kept (as the editor writes it) Dropped
Paragraphs, headings, lists, blockquotes, code blocks, line breaks, rules, tables <script>, <style>, <svg>, <form>, <object>, <embed>, <link>, <meta>, <base>, comments, CDATA and any tag the editor has no node for
Bold, italic, strike, underline, code, sub/superscript Every event handler (onclick, onerror, ...) and every attribute the schema doesn't define, id and srcdoc included
Links to http, https, mailto, tel (and the other protocols Tiptap allows, such as ftp and sms) and relative URLs, with atom's own target / rel Links whose href is javascript:, vbscript:, data: or another scheme (the text stays, the link goes); a link's own class, rel or target
Images with an http(s), relative or raster data:image/ source An image whose source is javascript:, data:image/svg+xml or another scheme (the whole image goes)
color, background-color, font-size, text-align, image width / float / align when the value passes the allow-list style declarations that fail it (position, url(...), expression(...), out-of-range sizes), and every class except mention, a font-size preset and language-*
Mentions (<span class="mention" data-type="mention" data-id data-label>), escaped The data-mention-suggestion-char marker the JS adds
YouTube embeds in a JSON document, rebuilt from the video id Any <iframe> in HTML input, and any iframe that isn't YouTube
Text that looks like markup (&lt;script&gt;), as escaped text Invalid UTF-8 (scrubbed, the text around it kept)

Two things it does not do:

  • It doesn't check a mention's data-id. It is escaped, but it is whatever the client sent. If a mention does anything (a notification, a link to a record), look the id up on the server, scoped to what the current user may mention, and ignore the rest.
  • It doesn't check that the caller may post. Authorise the request as you would any other write.

Upgrading: render() and <atom:tiptap.content> refuse what they can't parse safely

Content::render() (and so <atom:tiptap.content>) reads stored content, and it now renders empty, and logs a warning, for stored content that is over its limits, has a <pre> block or count of <pre> tags the parser can't survive (see above; long whitespace runs and unclosed <pre> tags are normalised, not refused), carries the minifier's placeholder token (MINIFYHTML), or is not a Tiptap document. Nothing is reported through report(). The warning (Log::warning, once per value per process) carries the reason, the size and the limits, so check your log after upgrading if a page that used to show content is blank.

The defaults are 2 MB and 20,000 tags (HTML tags, or nodes and marks of a JSON document; worst case about 64 MB of memory for HTML and up to about 82 MB for a JSON document). Raise or lower them for your app in config/atom.php:

return [
    'editor' => [
        'render_max_bytes' => 4 * 1024 * 1024,
        'render_max_tags' => 40000,
    ],
];

Memory grows with the tag limit (about 3 KB per tag or node), so raising render_max_tags needs a matching increase in PHP's memory_limit: at the defaults a page that renders one such document can peak near 64 MB (HTML) or 82 MB (JSON) above baseline, and doubling the limit roughly doubles that.

A long legitimate document (a 1,000-row price table, a 3,000-paragraph article) is within the defaults. A value that is not a Tiptap document at all (one only a hostile client can send) renders empty without a warning.

Actions

Named PHP classes you can invoke from PHP or, if they opt in, from the browser.

Resolution order:

  1. App\Actions\{Name} (host app — wins).
  2. Jiannius\Atom\Actions\{Name} (package fallback).

From PHP

Any action, any public method:

atom()->action('Customer.Search', ['q' => 'jane']);
atom()->action('Customer.Search', ['method' => 'byEmail', 'email' => 'jane@acme.test']);

From the browser

Atom mounts POST /atom/action/{name} and the front-end exposes window.atom.action(name, params). That endpoint is public — no auth, reachable by anyone who can load the app — so it only runs actions that opt in by implementing Jiannius\Atom\Contracts\WebAction:

// app/Actions/Customer/Search.php
namespace App\Actions\Customer;

use Jiannius\Atom\Contracts\WebAction;

class Search implements WebAction
{
    public function handle($params)
    {
        return \App\Models\Customer::query()
            ->where('name', 'like', '%'.$params['q'].'%')
            ->take(10)
            ->get(['id', 'name']);
    }
}
const result = await window.atom.action('Customer.Search', { q: 'jane' });

The endpoint:

  • Answers 404 for anything that did not opt in — the same answer an unknown action gets, so it cannot be used to enumerate your action classes.
  • Only ever calls handle(). method is a PHP-side convenience; over HTTP it is stripped from the params and ignored.
  • Encodes the return value straight to the caller. Return the columns you mean to expose, not whole models.
  • Does not rate-limit.

Gating who may call it

Declare authorize() on the action. The endpoint calls it before handle() and answers 403 when it returns false:

class Search implements WebAction
{
    public function authorize($params) : bool
    {
        return auth()->check();
    }

    public function handle($params) { /* ... */ }
}

Actions without authorize() are callable by anyone, including guests — which is right for something like GetOptions (country and dial-code lists on public forms) and wrong for almost everything else. An action inheriting from an opted-in parent inherits the contract.

Upgrading to 3.37.2

Content::sanitize() and Content::render() now print the same thing on every libxml, bar the malformed-HTML cases listed below. PHP on Ubuntu 22.04 and 24.04 and the Debian php images is built against libxml 2.9; a newer build (2.14 and later, as in most local setups) reads some HTML differently, and on 2.9 a few inputs made tiptap-php throw (a report() on every call, for input anyone can send). Content that comes from the editor or the chat composer is not affected. This release also contains a security fix in sanitize() and render(); upgrade soon if either handles input from users. Output cached or stored by them before the upgrade is not repaired.

  • A processing instruction (<?xml ...>, <?php ...>, <?span>) is dropped, up to the first >, and leaves a space where it was. On libxml 2.9 <p>x<?span></p>, <?td>, <?a> and <?img> threw, and <?p> printed a nested paragraph. Real HTML from a pasted Word document starts with one; it was dropped before and still is.
  • Loose top-level text is wrapped in a paragraph (42 prints <p>42</p>, a<p>b</p>c prints <p>a</p><p>b</p><p>c</p>), which is what libxml 2.9 always did. A newer libxml printed it bare, so resanitize() of a stored row was not idempotent there. A stored JSON document is printed as it was written, and a lone image stays as it is.
  • A blank line (two or more newlines) in the middle of text is read as one space, outside a <pre> (blank lines in a code block are kept). The HTML parser's minifier used to delete such a run and join the text on either side of it after it had been checked.
  • Hand-written HTML with a stray < (a < b) prints differently on libxml 2.9.13 (Ubuntu 22.04), which drops the text after it, than on 2.9.14 (Ubuntu 24.04, the Debian php images) and later. The editor and the chat composer write &lt;.
  • atom:tiptap-migrate skips a row it cannot convert (empty, over the limits, too deeply nested, or one the parser fails on), says which, and goes on; it used to stop at the first one. It converts through the same checks as render().
  • A NUL byte is dropped from the input (libxml 2.9 read it as a space, a newer one skipped it).
  • A document nested more than 200 levels is refused (a doc counts as one), as an over-limit input is: sanitize() returns '' silently and sanitizeRefuses() is true, render() logs the reason (depth). libxml stops at 256 levels, and on 2.9 one level of that was enough for the serialiser to fail with a warning. Real content nests a handful of levels.
  • Rows already stored are unchanged, except that stored HTML whose text sat outside any paragraph now prints inside one.
  • Still different between the two: malformed HTML that leans on the parser's repairs (text followed by a <div>, an unclosed <b> around a table, a stray </p>) can print differently on 2.9 and 2.14+. Nothing throws, and the output is schema-only either way.

Upgrading to 3.34.4

This is a security fix. Upgrade soon if any label, badge colour or embed icon in your app can come from a user or the database. Component labels were printed as HTML, so a label built from user-entered text (<atom:badge :label="$contact->name">) could run script in the page. A badge color that only started with # was written into the style attribute, so it could add CSS declarations.

  • Labels are text now in badge, radio, checkbox, toggle, rating, slider, tab items, every form field label (input, textarea, select, date-picker, time-picker, radio.group, tiptap) and the button label of the generic mail. Nothing to do if you pass plain text.
  • A label that already contains HTML entities (Tom &amp; Jerry) is now shown literally. Pass the raw text (Tom & Jerry).
  • A label that carries markup on purpose now shows the tags as text. Wrap it in Illuminate\Support\HtmlString (:label="new HtmlString('<b>New</b>')"), and escape every user or database value inside it with e(). t() passes an HtmlString through unchanged.
  • <atom:embed>'s icon only prints raw SVG from an HtmlString. A plain string starting with < now shows the file icon. Named icons are unchanged. This replaces the 3.29.12 note below.
  • Toast, alert and confirm heading, subheading and message, and breadcrumb titles, are sent to the browser as plain text. An HtmlString there shows its tags as text; they were never rendered as HTML.
  • A badge color is a hex colour only when it is #rgb, #rgba, #rrggbb or #rrggbbaa. Any other value starting with # renders the default grey badge. Valid hex colours render as before, and a 4-digit hex (#f00a) now gets the right shade (it used to be wrong).

Upgrading to 3.29.14

This is a security fix for stored Tiptap JSON documents. Upgrade soon if any user can write to an AsTiptapContent column, to an <atom:tiptap> field, or to anything you pass to Content::sanitize() or Content::render() as JSON.

A client that can save a JSON document could get markup of its choosing into what Content::render(), <atom:tiptap.content> and Content::sanitize() print (stored XSS). The cause is in tiptap-php: a zero in a node's attributes (an int 0, or a float that is zero such as 0.0) makes its serialiser print the node's other string attributes as raw tags instead of escaped attribute values. Two shapes were exploitable: an image whose alt, title or height was zero, and a mention whose id was zero; and a table cell (tableCell or tableHeader) whose colspan or rowspan was zero and whose colwidth held a string. From 3.29.14 a zero in any node or mark attribute is turned into the string "0", which prints the same inside an attribute, before the document reaches the serialiser.

  • HTML input was never affected. HTML attributes arrive as strings, and tiptap-php's HTML parse turns the numeric ones (start, colspan, rowspan) into null when they are zero, so a zero never reaches the serialiser that way. Chat HTML from <atom:tiptap.chat> was not exposed, unless your code passed it a JSON document.
  • Stored JSON needs no backfill, but it is not rewritten either. The stored document still holds the zero; render() and <atom:tiptap.content> repair it on the way out, so every print through atom is safe after the upgrade. Anything that prints the column some other way was never covered and still is not: {!! !!} or x-html straight from the column, your own tiptap-php Editor, or JavaScript that loads the stored document.
  • HTML that was rendered before the upgrade is not repaired. Anything that stored or cached the output of render() or sanitize() while the hole was open (a rendered email, a page or fragment cache, a PDF, a search index) still holds whatever it printed. Clear those caches; atom cannot reach them.
  • Backfill only if you ever passed JSON to sanitize() and stored the result. sanitize() accepts a JSON document and returns HTML, so a row stored from a hostile document before 3.29.14 may hold stray markup as HTML, and that markup can include an <iframe>. Run those rows through Content::resanitize(), which is sanitize() for a row that sanitize() wrote: sanitize() itself would drop the YouTube embeds a row legitimately holds (HTML parsing drops every <iframe>; an embed survives only in a JSON document). resanitize() sets aside only an embed that is exactly what atom prints (a div and an iframe with the attributes it sets, a src rebuilt from the 11-character id on youtube.com or youtube-nocookie.com, an optional ?start=), sanitizes everything else, and puts the embeds back. Any other <iframe>, a srcdoc or an extra attribute included, is cleaned away with the rest of the hostile markup. The placeholder it uses is 128 random bits, new for every call, so nothing in a row can forge or predict it: a row that contains the word ATOMEMBED, or a guess at the token, is cleaned like any other. A clean row comes back unchanged and is not written. A row that comes back '' is not rewritten; it is either over the size limits or has nothing printable in it, so the snippet lists those ids and you look at each by hand (blank or delete it: render() never prints it, but anything that prints the column raw would):
use Jiannius\Atom\Tiptap\Content;

// once, after upgrading; Message is your model, body the column that sanitize() wrote
$review = [];

Message::query()->chunkById(200, function ($messages) use (&$review) {
    foreach ($messages as $message) {
        if (! is_string($message->body)) {
            continue;
        }

        $clean = Content::resanitize($message->body);

        if ($clean === '') {
            if (trim($message->body) !== '') {
                $review[] = $message->getKey();
            }

            continue;
        }

        if ($clean !== $message->body) {
            $message->forceFill(['body' => $clean])->saveQuietly();
        }
    }
});

// the rows nothing was written for: look at them by hand
logger()->info('resanitize() backfill: rows to review', ['ids' => $review]);

If you only ever stored chat HTML, or JSON through AsTiptapContent, skip it. Do not pass JSON through sanitize() from now on either (see the section above); it is for HTML.

  • A heading level no longer reaches the tag name as sent. tiptap-php checks it with a loose in_array(), so " 1", "\n1", "+1" and "6 " made createElement('h 1') throw (a report() on every call, and sanitize() returned '' with sanitizeRefuses() false), and "1e0", "01" and "1.0" were stored as <h1e0>, <h01> and <h1.0>. A level that is an int from 1 to 6, or a string that is exactly one of those digits once trimmed, is kept; anything else (7, 0, -1, 1.5, true, "01", "1e0") becomes level 1, as a missing level already did. The editor only ever sends 1 to 6.
  • Other whole-number attributes are checked the same way. An ordered list start, a cell colspan and rowspan (at least 1) must be an int or a string of digits, and anything else is dropped, as it is when the same value comes in as HTML. A colwidth entry may also be a finite float, which is rounded to a whole number; an entry that is not a number drops the whole colwidth. A code block's language was already limited to one token of name characters.
  • sanitize() refuses output over $maxTags, not only input. A document of 5000 nodes could print 20000 tags (about 5,000 empty tables were 85 KB in and about 20,000 tags out, just under render()'s limit), stored and printed on every view. Now the output is counted (< characters) against $maxTags too, and a document that prints more comes back '' with sanitizeRefuses() true. Real content is nowhere near it: what the chat composer sends prints exactly as many tags as it had, and a long article (2,900 nodes, 105 KB of JSON) prints about 2,600, so the byte limit binds first. What can now be refused is hand-written HTML that leans on the parser's repairs: <p><b>a</b>x prints four tags for three, so about 1,250 of them in one message reach the limit. Measured with the default limits, a list of <li><p> items is refused from 1,250 items (1,249 is kept, 118 KB of JSON), and a table of 5 columns from 228 rows (227 is kept; 3 columns, 356 rows; 8 columns, 146 rows). Raise $maxTags (and $maxBytes, which sits close behind at about 120 KB for those shapes) for a checklist or table-heavy field. Content::render() is not capped this way; it reads stored content under its own limits.
  • sanitizeRefuses() now converts the input (once the cheap checks on the input pass) to measure the output, so it agrees with sanitize(). It also answers true for output over four times $maxBytes, which it used to miss. Call it after sanitize() has returned '', as the example above does, and it costs one more parse only for a message that is being rejected anyway.
  • A document that cannot be written back as JSON is now refused silently. A number that overflows (1e999) made tiptap-php's json_encode() return false, and an object key that starts with a NUL byte (\u0000) made it read content on null. Either one was a report() on every call, and from render() one per view of a stored row. Such a document now renders '' with no report and no log line, and sanitizeRefuses() is true. Only a hostile client sends one.
  • Nothing to do for documents written by the editor: their output is unchanged.

Upgrading to 3.29.13

A URL handed to an atom component used to be printed into an href (or navigated to) as it came. {{ }} escapes the HTML, but a javascript: URL still runs when clicked, so a host that passed a user-supplied URL (a profile "website", a link from a CMS) let that user run script in a visitor's browser. Atom now checks the URL first, with safe_url() in PHP and atom.safeUrl() in JS.

Allowed: http, https, mailto, tel, sms, and any URL with no scheme (/path, path, //host/path, #anchor, ?query). Anything else (javascript:, vbscript:, data:, blob:, file:, and versions hidden with upper case, HTML entities, tabs, newlines or leading control characters) is dropped: the link renders without an href, a button or row does not navigate, and a toast or lightbox does not open it. It covers <atom:link>, <atom:button>, <atom:tabs> / <atom:tabs.item>, <atom:menu.item>, <atom:list.item>, <atom:command.item> (and Enter in the palette), <atom:navlist.item>, <atom:table.row>, the <atom:logo> link, the breadcrumbs trail and its back action, toast url / navigate, the lightbox download, and the call-to-action button in the generic mail template (atom()->mail(cta: ...)), which is left out when its URL is blocked.

  • Nothing to do for route(), url() and asset() output, ports, query strings, fragments, IDN hosts, mailto: and tel:, or wire:navigate links. They are unchanged.
  • A relative path whose first segment contains a colon is now read as a scheme and dropped. href="foo:bar" and href="10:30/agenda" no longer render; write ./foo:bar (or /foo:bar). A colon later in the path (/time/10:30), in the query or in the fragment is fine.
  • Other schemes (ftp:, whatsapp:, viber:, an app deep link) are dropped too. If you need one, render your own <a> for it.
  • Your own markup is not covered. An href, formaction, window.open() or location you write around a user-supplied URL needs safe_url($url) (PHP) or atom.safeUrl(url) (JS) first. That check is about the scheme only: //evil.com is allowed, so a redirect to a user-supplied URL must also check the host. <atom:embed> keeps its own check for now.

Upgrading to 3.29.12

<atom:sharer> printed title and url, and <atom:embed> printed an image src, into HTML attributes without escaping, so a value containing a quote could add attributes to the element; <atom:error :errors="..."> printed each message as HTML. They are escaped now. <atom:embed> also refuses a src whose scheme is not http or https (relative and protocol-relative URLs still work) and shows the file icon instead.

  • Nothing to do if you pass plain text and normal URLs. &, quotes and CJK text reach sharer.js as typed; it reads the attributes with getAttribute() and URL-encodes them itself.
  • A title, url or error message that is already HTML-encoded (Tom &amp; Jerry) is now shown literally. Pass the raw text (Tom & Jerry).
  • An error message that carries markup on purpose (atom:error :errors="[...]" with <b>) now shows the tags as text. Laravel's own validation messages never do.
  • An embed src on another scheme (javascript:, data:, blob:) no longer renders. Nothing in atom's own file->url produces one.
  • embed's icon prop was trusted markup when it started with <svg. Since 3.34.4 only an HtmlString prints as SVG; see Upgrading to 3.34.4.
  • <atom:sharer> needs sharer.js on the page. atom has never bundled it. Without it the component used to throw Sharer is not defined in the console; it now stays quiet, but the share buttons do nothing until you load it, for example <script src="https://cdn.jsdelivr.net/npm/sharer.js@0.5.4/sharer.js"></script>. The copy-link button does not need it.
  • Two share buttons that never worked are fixed (checked against sharer.js 0.5.4). The X button sent data-sharer="twitter-x", a key sharer.js does not have, so it did nothing; it now sends x and keeps its icon and label. The email button opened a blank popup for its mailto: link; it now carries data-link="true", so the mail client opens. sharer.js opens its popups without noopener and has no option for it, and atom does not patch it: the popups only ever go to the social sites' own addresses.
  • Content::sanitize() and Content::render() no longer report an error for input with nothing to render. A script-only, style-only, comment-only, <html></html> or head-only value used to hit report() on every call (a TypeError from tiptap-php). They now return '' without a report or a log line. Content that is only whitespace, Unicode spaces such as U+3000 or a no-break space included, renders '' the same way. Two nested-mark shapes (<code>><code>c and <p><strong>a<em><strong>c</strong></em></strong></p>) threw an ErrorException from tiptap-php, reported on every call; atom now folds the marks of one type on a node into one (the attributes are merged, a later value winning only for the same key, and a link keeps its first valid href), which also repairs the malformed output simple repeats such as <code><code>c</code></code> used to produce (nested duplicate tags). If you filtered these out of your error tracker, you can drop the filter.

Upgrading to 3.29.9

Remote and static select options used to be turned into HTML without escaping label, caption or color, so a value a user typed (a contact name, say) rendered as markup. They are escaped now, including the native select's <option> text.

  • Nothing to do if your option sets return plain label/caption text.
  • A label that already contains HTML entities (Tom &amp; Jerry) is now shown literally. Return the raw text (Tom & Jerry).
  • The html key stays trusted. If a set builds its own html, atom still passes it through untouched, and you must wrap every interpolated user or database field in e(). Grep your App\Actions\GetOptions (and any code that sets html or selected_html on an option) for interpolated model fields. See Option text is escaped.

Upgrading to 3.25

3.25 makes the trait's action() helper protected. It was public, and Livewire exposes every public method a component declares — a trait method included, because reflection reports the using class as the declaring class. So on every component using AtomComponent, in every app:

$wire.action('SomeAction', { method: 'whicheverMethod', ...params })

reached any App\Actions\* class and any method on it, with no WebAction contract, no authorize(), and no allowlist. That is the hole 3.19 closed on POST /atom/action, standing open through Livewire the whole time. On a guest-facing page it was reachable without signing in.

What breaks: JS that calls the helper on the wire proxy. Find it with both spellings — Livewire proxies an exposed method under its own name, so the obvious grep misses most of it:

grep -rn "\$wire\.action(\|@this\.action(\|call('action'\|call(\"action\"" resources/

Migration, per hit: move the call to the public route, which has been the gated path since 3.19 — window.atom.action(name, params) — and opt the action in with implements \Jiannius\Atom\Contracts\WebAction plus an authorize(). See From the browser. If the call passed method, split that method into its own action; the route ignores method.

What does not change: server-side $this->action(...) inside a component, and atom()->action(...) from anywhere. Both still reach any action and any method — being protected only closes the browser door.

Upgrading to 3.20

3.20 closes three holes in GetOptions, the one action the package ships web-callable without authentication. All three were reachable by an anonymous caller:

  • The option name picked a method. {"name":"purge-cache"} invoked purgeCache() on your subclass. method_exists() matches private, protected and inherited methods, and the call was made from inside the class, so all of them were reachable.
  • The option name built a file path. {"name":"../../composer"} escaped resources/json/ and read the file.
  • An unknown name threw, returning 500 with the absolute path in the log — an unauthenticated log flooder that also fired on an honest typo (country instead of countries).

What you must do, in every app with its own App\Actions\GetOptions: declare each option set in $auth (needs a signed-in caller) or $guest (readable by anyone). Undeclared sets return [] — the select goes empty — and log a warning naming the set. See GetOptions above.

Assume $auth unless you can say why a stranger may read every row. Sets like contacts, users, documents or taxes were guest-readable before this release; declaring them $guest keeps them that way.

$auth gates who is signed in, not which rows they get. Scope the queries too.

Upgrading to 3.19

Before 3.19 the endpoint ran any class in App\Actions\ — unauthenticated, and with the caller choosing which public method to invoke. It is now closed by default, so any action your JS calls goes dark until you opt it in.

  1. Find them: grep -rn "atom.action(" resources/ (and any .js outside resources/). Each name maps to a class — Foo.Bar → App\Actions\Foo\Bar.
  2. For each, add implements \Jiannius\Atom\Contracts\WebAction. Nothing else changes.
  3. While you are in each file, decide whether it should have been public at all. Add authorize() to anything that reads or writes user data — before 3.19 it had no gate, so assume none of them do.
  4. If you have your own App\Actions\GetOptions, make it extends \Jiannius\Atom\Actions\GetOptions — otherwise it shadows the package class without the contract and every remote-option select (<atom:select options="users">) in the app 404s.
  5. If any JS passed method in the params, split that method into its own action — the endpoint ignores method now.
  6. Check the app's CLAUDE.md/AGENTS.md. Atom's guidelines are copied into it rather than read from vendor/, and Boost rewrites that file on composer update — so the new rules arrive on their own, but any hand-edits you made to the atom section are overwritten at the same time. Re-apply them, or keep them outside that section.

Anything you do not opt in stays fully callable from PHP; only the browser path is affected.

If you miss one, the log says so. A refused action that exists writes a warning naming the class and the endpoint:

[atom] Refused POST /atom/action/customer.search: App\Actions\Customer\Search does not
implement Jiannius\Atom\Contracts\WebAction. ...

Requests for actions that don't exist stay silent, so this doesn't turn into noise from probing.

GetOptions: shared option lists

Jiannius\Atom\Actions\GetOptions loads option arrays from JSON files. It merges:

  • Package JSON at <package>/json/{name}.json
  • App JSON at resource_path('json/{name}.json')

with the app-side values taking precedence. Results are cached under _options.

Built-in JSON sets: countries, postcodes, colors. Override any of them by creating resources/json/colors.json in your host app.

To serve options from your database instead, create App\Actions\GetOptions extending the package class (it shadows it, and extending is what carries the WebAction contract that remote-option selects need). Each option set needs two things: a camelCase method, and its name declared in $auth or $guest.

Reach it by passing the set name as a string on options — that is what makes the select fetch rather than render a static list:

<atom:select variant="listbox" options="users" wire:model="user_id" searchable />

:options="[...]" (an array) is a static list; name is the field name and never selects an option set.

namespace App\Actions;

class GetOptions extends \Jiannius\Atom\Actions\GetOptions
{
    /** Readable only by a signed-in caller. */
    protected array $auth = ['users'];

    /** Readable by anyone, guests included. */
    protected array $guest = ['brands'];

    public function users() : array
    {
        return \App\Models\User::query()
            ->where('team_id', auth()->user()?->team_id)
            ->get()
            ->map(fn ($user) => ['value' => $user->id, 'label' => $user->name])
            ->all();
    }
}

Why the declaration: the option name arrives in the request body, and it used to be turned straight into a method call. Anything zero-arg on your subclass was reachable from a browser, authenticated or not. Now the name only selects among the sets you listed, and a name in neither list returns an empty array.

  • $auth — needs a signed-in caller. Everything backed by app data belongs here. A guest gets 403.
  • $guest — readable by anyone. Only for sets where every row a stranger could pull back is safe to hand over.
  • Undeclared — returns []. If a method of that name exists, a warning is logged naming the class and the set, so a select that has gone empty is diagnosable.

The package's own sets (countries, states, dialcodes, currencies, colors, postcodes) are always readable — guest address and phone forms need them — and you don't re-declare them.

$auth is a coarse gate: signed in or not. It does not scope rows. A signed-in user of tenant A calling a set that returns every tenant's rows still gets every tenant's rows, so scope the query itself as well.

Option text is escaped; the html key is trusted

The listbox renders each option's markup with x-html. An option is a value plus a label, and optionally caption, avatar and color. Atom builds the markup from those fields and escapes every one of them, both on the server (GetOptions::getOptionHtml()) and in the client-side fallback (select.js). A contact name a user typed renders as text, whatever it contains. Return the raw text in label and caption; don't pre-escape it, or it is escaped twice. color is checked as well as escaped: only #hex, rgb()/rgba(), hsl()/hsla() or a plain colour name draws a swatch, and anything else (red; position: fixed) draws none.

An option may instead carry its own html (and selected_html, the version shown in the closed trigger). That key is the escape hatch for a custom layout, and it is trusted: atom passes it to the browser untouched, so you own escaping it. Wrap every user or database value you put in it with e():

['value' => $doc->id, 'label' => $doc->name, 'html' => '<div>'.e($doc->name).' <small>'.e($doc->number).'</small></div>']

An unescaped field in html is a stored XSS for everyone who opens that select. Keep label plain even when html is set: the label is what search filters on and what the chips show.

The native variant prints label into <option>, and escapes it.

Translation

t('Some string', $countOrParams, $params) is the translation shim. Almost every UI string in components passes through it, so to translate your app you just drop standard Laravel translation files under lang/{locale}/.

t('Save changes');                      // → __('Save changes')
t('item.count', 5);                     // → trans_choice('item.count', 5)
t('Hello :name', ['name' => $user]);    // → __('Hello :name', ['name' => $user])

Front-end JS API

resources/js/atom.js is built to dist/ and served by the package; it boots automatically when <atom:html> renders. It exposes:

  • window.atom.safeUrl(url) — the URL unchanged if it is safe to bind to an href or navigate to, else null. The JS twin of safe_url() (scheme only: a host check is still yours for a redirect); use it before Livewire.navigate(), window.open() or x-bind:href on a URL you did not write.
  • window.atom.action(name, params) — POST to /atom/action/{name} (actions implementing WebAction only; see Actions).
  • window.dd(...args) — console.log dump.
  • window.empty(value) — truthy-empty helper.
  • Alpine factories: modal, editor, select, tooltip, dropdown, lightbox, telInput, emailInput, breadcrumbs, datePicker, timePicker, dateRange, calendar, plus chart variants.
  • $clipboard Alpine magic.
  • Prototype additions on Array, Number, String (see resources/js/prototypes/).
  • Alpine plugins loaded: @alpinejs/intersect, @marcreichel/alpine-autosize.

Conventions worth knowing

  • Window-level Livewire events are prefixed atom- (atom-modal-show, atom-modal-close, atom-toast-show, atom-alert-show, atom-confirm-show). Grep by this prefix when tracing overlay state.
  • <atom:button type="delete"> auto-dispatches confirmed on accept, which (unless overridden) calls $wire.delete().
  • Date handling everywhere goes through Jiannius\Atom\Services\Carbon because of the Date::use() swap in the service provider.
  • Components prefer Arr::toCssClasses([...]) and match over conditional class strings. See components/button/index.blade.php for the canonical pattern.
  • The bundled dist/ directory is committed; the package serves it itself. If you fork and edit JS/CSS sources, run npm run build and commit dist/.
  • atom.css defines [x-cloak] { display: none !important }, so x-cloak works out of the box in any app loading atom's stylesheet — Tailwind ships no such rule, and without one the attribute is inert. The !important matters: [x-cloak] and a utility like .flex have equal specificity, so otherwise the winner is whichever stylesheet loads last. Alpine strips the attribute on init, so the rule only ever hides pre-boot markup — which also means x-cloak must sit on an element Alpine will initialise (one carrying x-data, or inside one), or it stays hidden forever. tests/Feature/XCloakTest.php enforces both halves.

Artisan commands

Command Purpose
atom:purge-editor-images Walks App\Models\*, finds columns cast as AsEditorContent or AsTiptapContent, and moves any unreferenced editor image to editor-purged/ on the local disk before removing from the configured disk. A file is referenced if its name appears anywhere in a stored value.
atom:purge-editor-images --dry-run Lists what it would delete and changes nothing.
atom:purge-editor-images --force Empties the editor-purged/ backup folder.

License

MIT. See LICENSE.md.