Shortcodes are short [[tag]] tokens you can drop into any text or rich-text field. At render time they expand to the current value — a phone number, a business address, the current year, a snippet of HTML, the title of the post the user is viewing. Edit the tag's value in one place (Settings, the Shortcodes admin, or a content item's title) and every page that uses the tag updates on the next request.
The problem
A growing site accumulates dozens of places where the same value is spelled out: the contact phone, the support email, the office address, the current year in the footer, the company's full legal name. When that value changes — a new phone provider, a holiday closure, an entity rename — finding and updating every page is the kind of grunt work that gets skipped, leaves stale data on the public site, and erodes trust.
The two usual workarounds both fail. Hardcoded values everywhere need find-and-replace passes that miss inflected variants. Per-page custom fields hide the value behind one more click and stop being usable in places where there's no field — a paragraph mid-sentence, a button label, a meta description.
The fix
Three categories of shortcode share one syntax: [[tag]] (or [[field:key]] for the per-item variant). At render time, ShortcodeProcessor splits the content on the token regex, looks each tag up, and substitutes the resolved value.
- Database shortcodes live in the
shortcodestable and are managed at Dashboard → Shortcodes. Editors create them with a name, a tag, a type (single text, rich text, or PHP), and content. Activate / deactivate without deleting. - System shortcodes are read from settings via
SystemShortcodes— phone, email, address, business hours, the site name. Edit them once on the Business Information settings page and every[[business_phone]]on the site reflects the new value. - Per-item field shortcodes (
[[field:key]]) resolve against the current content item's data. Used inside content-type templates so[[field:title]],[[field:author]], etc. expand against the row being shown.
Resolution priority is DB shortcode → system shortcode → unchanged token. So a site can override business_phone with a database row when a custom routing or formatting is needed, without losing the system fallback.
What lives where
| Path | Purpose |
|---|---|
app/Support/ShortcodeProcessor.php |
The processor. process() and processRaw() differ only in HTML escaping (see below). containsShortcodes() is a fast [[…]] regex check used before invoking the processor. setItemContext() / clearItemContext() bracket per-item rendering. |
app/Support/SystemShortcodes.php |
Built-in tags backed by settings. Single source of truth for the tag list — everything in all() is what the Shortcodes admin lists in the "Built-in" tab. |
app/Models/Shortcode.php |
Eloquent model. activeByTagCached() reads the active set from Cache::rememberForever('shortcodes:active', …). Saved/deleted hooks invalidate the cache, so warm pages issue zero shortcode queries. |
resources/views/pages/dashboard/shortcodes/⚡index.blade.php |
Admin index — DB shortcodes list with toggle/delete, plus a "Built-in shortcodes" reference panel listing all system tags and current values. |
resources/views/pages/dashboard/shortcodes/⚡create.blade.php / ⚡edit.blade.php |
Create / edit forms. |
app/helpers.php |
The content() helper runs every text/richtext value through ShortcodeProcessor::processRaw() when containsShortcodes() returns true — so any field rendered via content() automatically supports tokens. |
resources/views/partials/shortcode-modal.blade.php |
Editor "insert shortcode" modal — pickable list of every active DB shortcode + every system shortcode. |
Tag syntax
[[tag]] — three forms are recognised, defined by the regex /\[\[[\w-]+(?::[\w_-]+)?\]\]/:
| Form | Resolves to | Example |
|---|---|---|
[[tag]] |
DB shortcode by tag, then system shortcode by tag |
[[business_phone]] → (555) 123-4567 |
[[field:key]] |
The current content item's data.key (only when setItemContext() was called) |
[[field:title]] → the post's title |
[[unknown]] |
Returned literally (no replacement) | [[unknown]] → [[unknown]] |
Tags can include letters, numbers, hyphens, and underscores. The optional :key portion is only used for the field: prefix today — other prefixed forms could be added the same way without breaking the regex.
Built-in system shortcodes
Every value comes from settings (Business Information page) or from the application config. Editing the source updates every site page that uses the tag on the next request.
| Tag | Source |
|---|---|
[[site_name]] |
config('app.name') |
[[business_phone]] |
business.phone setting |
[[business_email]] |
business.email setting |
[[business_url]] |
business.url setting |
[[business_hours]] |
business.hours setting |
[[business_address_street]] |
business.address_street setting |
[[business_address_city_state_zip]] |
business.address_city_state_zip setting |
[[business_address]] |
Both address parts joined by , |
DB shortcodes can override any of these — if a site has a business_phone row in shortcodes it takes priority over the system value.
Database shortcode types
Three types control how the content is rendered:
| Type | Behaviour | Use when |
|---|---|---|
single_text |
Content is HTML-escaped on insertion via process(); passed through unescaped via processRaw() (caller is expected to escape). |
A plain-text value — phone number, email, short label. |
rich_text |
Content is inserted raw (never escaped), so HTML tags inside the shortcode render. | A reusable HTML block — a styled CTA button, a callout card, a banner. |
php_code |
Content is eval()'d inside an output buffer; output is captured and inserted raw. Errors are swallowed (no white-screen). |
Dynamic values like the current year, a formatted timestamp, a per-request greeting. |
process() vs. processRaw()
The processor exposes two entry points that differ only in how they treat non-shortcode chunks:
process($content)— splits the input on the shortcode regex and runshtmlspecialchars()on every plain-text chunk. Single-text shortcodes are also escaped on insertion. Rich-text shortcodes are inserted raw. Returns a string safe to echo with{{ }}(which would re-escape an already-escaped string — don't do that), or with{!! !!}for raw output.processRaw($content)— splits the same way but inserts every chunk verbatim. Suitable when the input is already trusted HTML — Tiptap rich-text output, content that has been escaped at a different layer, content rendered through a Blade component that handles its own escaping.
The rule of thumb encoded in the codebase: content() always uses processRaw() because the value is rendered via a Blade component (<x-dl.heading> etc.) that handles output. Direct echoes of plain text in older Volt templates use process() so the plain-text chunks get escaped.
Caching — zero queries on warm pages
Shortcode::activeByTagCached() reads the active set into Cache::rememberForever('shortcodes:active', …) keyed by tag. The model's saved and deleted hooks call Cache::forget(), so any change in the admin invalidates the cache. A warm page that uses any number of [[…]] tokens issues zero shortcode queries — the entire active set is already in cache.
ShortcodeProcessor reads from this cache directly; there's no per-request memoisation in the processor itself. Adding one would only help if the cache layer were slow, which it isn't for an array-keyed Eloquent collection.
Where shortcodes work
- Every text and richtext field rendered via
content()— which is every editable field on every design library row, header, and footer. Tokens are resolved automatically. - Blog post content — same pipeline.
- Custom content type fields —
ContentTypePageGeneratoremits the rightprocess()/processRaw()call for each declared field type. - Manual call sites — anywhere you have user-typed content that should support tokens, call
ShortcodeProcessor::process()(orprocessRaw()if the input is trusted HTML).
Per-item context ([[field:key]])
Shortcodes can also resolve against the current content item — the post being viewed on a blog show page, the event on an event detail page. The pattern is to call ShortcodeProcessor::setItemContext($item->data) in the page's Volt mount() and clearItemContext() after rendering. Inside that window, [[field:title]] resolves to $item->data['title'], [[field:author]] to $item->data['author'], etc.
process() HTML-escapes the field value; processRaw() inserts it verbatim — match the call to whether the surrounding pipeline has its own escaping layer.
Host-model context — content blocks ([[cta:slug]], [[gallery:slug]], [[faq:slug]])
A second context slot resolves dynamic shortcodes against the host model of the page — the ContentItem record backing a blog post, an event, or any other content type's detail page. Show templates call ShortcodeProcessor::processBodyContent($content, $host), a single helper that brackets setHostModel($host) / clearHostModel() around processRaw() and also auto-appends any host blocks the editor didn't place inline.
A content item (a blog post, an event, or any other content type) can own any number of content blocks — named CTA button groups, photo galleries, and FAQ sets — each addressed by a slugged shortcode:
| Tag | Renders |
|---|---|
[[cta:slug]] |
The named CTA button group |
[[gallery:slug]] |
The named photo gallery (responsive grid + click-to-zoom lightbox) |
[[faq:slug]] |
The named FAQ set (accessible accordion + FAQPage JSON-LD) |
A block's auto-append toggle decides whether it also renders at the end of the body when its shortcode isn't placed. Placing the shortcode anywhere suppresses the auto-append for that block (so it never renders twice). Multiple auto-append blocks render at the end in one shared sort order, so types can interleave.
Bare tokens (back-compat): [[gallery]] / [[faq]] (no slug) resolve to the host's first content block of that type. There is no bare [[cta]] — a cta block is always addressed by slug, so [[cta]] falls through to a DB/system shortcode of that name.
Orphan tags — no host set, no matching block — silently expand to an empty string.
See Blog and Inline blocks: FAQs and photo galleries for the full feature contract.
Marketing angle
Change one setting — phone number, address, year, support email, marketing tagline — and every page on the site updates the next time it's loaded. No find-and-replace, no plugin install, no engineer needed. Editors get a one-click "insert shortcode" picker in the editor; admins manage the reusable HTML blocks at one URL; system tags follow the Business Information settings without needing to be re-typed anywhere.