Reusable "coming soon" (pre-launch) and enhanced maintenance-mode holding pages for Laravel, with email-capture signup and launch/restore announcement notifications.
Hold gives you two holding pages that both capture email addresses and can later email everyone — once — when you're ready:
- Prelaunch ("coming soon") — a package-owned mode you toggle with a command. While active, global middleware intercepts every request (except the package's own routes and holders of a valid bypass cookie) and renders the prelaunch page with a configurable HTTP status.
- Maintenance — Laravel's native
php artisan down, left completely untouched. The package participates by shipping a maintenance capture page (resources/views/vendor/hold/maintenance.blade.php, rendered ondownvia a publishederrors/503.blade.phpshim) and by keeping its own routes reachable while the app is down.
Hold is the unified interface for both — one command pair (enable {mode} /
disable), with only one mode ever active at a time.
The package provides mechanism; your app owns orchestration. The migration and
the HoldSignup model are published into your app — you own them. Integration
is container-only: nothing edits bootstrap/app.php or any other core file, so
composer remove fully reverses everything.
- PHP
^8.4 - Laravel
^13.0
Hold is distributed as a plain Git repository (not Packagist). Add it to your
app's composer.json repositories, then require it:
composer require jamesgifford/hold
php artisan jamesgifford:hold:setupjamesgifford:hold:setup publishes the config, migration, model, and views,
creates the runtime storage directory, and offers to migrate:
# Interactive: publishes config, PAUSES so you can review/edit it, then honors
# your edits for every remaining step, and offers to run the migration.
php artisan jamesgifford:hold:setup
# Unattended (CI): skip the pause and every overwrite prompt, run the migration.
php artisan jamesgifford:hold:setup --force --migrateThe pause matters: right after the config is published, an interactive run stops so you can set your route prefix, team addresses, announce delay, model location, and so on. Every later step re-reads the (possibly edited) config — there are no hardcoded defaults past the pause.
Setup is idempotent: re-running never publishes a second migration and never clobbers an edited config (it prompts, or skips silently when unattended).
# Put up the "coming soon" page (prints a signed preview link)
php artisan jamesgifford:hold:enable prelaunch
# ...or take a live app down for maintenance (prints a secret bypass link)
php artisan jamesgifford:hold:enable maintenance
# Bring the app back — disables whichever hold is active
php artisan jamesgifford:hold:disable
# Email your signups that you're live / back
php artisan jamesgifford:hold:announceHold is the single interface for both holding modes, and only one may be active at a time. You drive both through two commands:
php artisan jamesgifford:hold:enable prelaunch # "coming soon" page
php artisan jamesgifford:hold:enable maintenance # native `artisan down`, managed by Hold
php artisan jamesgifford:hold:disable # end whichever hold is activeenable refuses (and names the active mode) if a hold is already up — run
disable first; there is no override. Every run ends with an Active hold: …
status line so the resulting state is unambiguous.
| Prelaunch | Maintenance | |
|---|---|---|
| Use when | Before you've launched — a "coming soon" teaser | Temporarily taking a live app down for work |
| Mechanism | Package flag file + global middleware | Laravel's native php artisan down |
| HTTP status | 200 (indexable) or 503, configurable |
503 |
| Your bypass | Signed preview link (per-activation token) | Laravel secret link (/{secret}) |
| Signup context | prelaunch → launch announcement |
maintenance → "we're back" announcement |
enable prelaunch writes a flag file under storage/jamesgifford/hold/ (so it is
independent of config cache) and prints a signed preview link; disable
clears it. The PrelaunchMode middleware is registered globally — when no
hold is active it is a near-zero-cost no-op (a single is_file() check). The
response status is configurable (prelaunch.status_code): 200 keeps the page
indexable, 503 signals "not yet available" to crawlers and uptime checks.
Preview links and bypass cookies are valid per-activation. Each enable mints
a fresh random token (stored as the flag file's contents); the preview link and
the bypass cookie both carry it, and a request is waved through only when its
token matches the current activation. Disabling the hold revokes every
outstanding preview link and bypass cookie — re-enabling issues a new token, so
old links (even with a still-valid signature) and old cookies stop working. Share
the link printed by the most recent enable.
enable maintenance runs Laravel's native php artisan down for you (with a
generated --secret) and prints the secret bypass link (/{secret}); disable
runs php artisan up. Laravel's maintenance mode is the untouched underlying
mechanism — Hold just manages it, keeps its own routes reachable so the capture
form works, and records signups with the maintenance context. When
auto_announce_on_up is enabled, up schedules the "we're back" announcement.
Native down/up still work directly (deploy tooling often calls them), which
bypasses Hold's one-hold check — so Hold self-heals: if prelaunch is active
when maintenance comes up natively, Hold automatically disables prelaunch (logging
an informational line) so only one hold is ever active.
⚠️ Deploy caveat: a deploy script that wraps the deploy inartisan down/upwill therefore knock the app out of prelaunch via self-heal. During the pre-launch phase, either skipdown/upin your deploy script or re-enable prelaunch (jamesgifford:hold:enable prelaunch) as the final deploy step.
Setup publishes the maintenance capture page as
resources/views/vendor/hold/maintenance.blade.php — edit it there. It also
publishes resources/views/errors/503.blade.php as a two-line shim that
Laravel renders on down; the shim just @includes the maintenance view:
{{-- Hold package: Laravel renders this on `artisan down`; edit maintenance.blade.php instead. --}}
@include('hold::maintenance')(Prelaunch's page is the sibling resources/views/vendor/hold/prelaunch.blade.php.)
--render renders the maintenance view by bypassing the HTTP kernel. The
signup form POSTs to a normal route, and with --render that route never runs —
the form silently fails. Always use a plain php artisan down (or
jamesgifford:hold:enable maintenance); the published errors/503.blade.php shim
and the route bypass handle the rest.
Both holding pages POST to /{prefix}/signup. The endpoint is deliberately quiet:
a bot-tripped honeypot, an over-the-limit IP, and a duplicate address all return
the same success response as a genuine new signup — it never reveals whether
an address is already on the list.
- Honeypot: a CSS-hidden field (
spam.honeypot_field, defaultwebsite). A filled value is treated as a bot: success is returned, nothing is stored. - Rate limiting:
spam.rate_limit_per_minute(default 5) per IP. - Context: recorded server-side —
maintenancewhen the app is down, elseprelaunchwhen a hold is active.
One row per email, lifecycle-aware. requested_at records when someone
requested notification for the current hold:
- New email → a row is created (
requested_at= now). - Same-cycle duplicate (the row hasn't been notified yet) → nothing is written; the row stays byte-identical.
- Re-signup during a later hold (the row was already notified) → the row is
re-armed:
notified_atcleared,requested_atreset,contextset to the current mode,ip_address/user_agentrefreshed.unsubscribed_atis never touched.
Each requested hold produces exactly one notification (the notified_at guard).
The signup route is CSRF-exempt by design: both holding pages render before
Laravel starts the session (prelaunch is global middleware; the 503 view renders
during an aborted maintenance request), so neither can embed a CSRF token. The
honeypot and rate limit guard the endpoint instead. Feedback is returned as a
?hold=subscribed|invalid query param the views read — again, because session
flash isn't available where these pages render.
Four notifications ship with the package, all sent via on-demand mail routes (no
User model required):
| Notification | Sent to | When |
|---|---|---|
TeamHoldEnabled |
your team addresses | a hold begins |
LaunchAnnouncement |
prelaunch signups | you announce a launch |
ServiceRestored |
maintenance signups | you announce a restore |
HoldSignupReceipt |
a new/re-armed signup (optional) | on capture, if enabled |
Unsubscribed rows (unsubscribed_at set) receive none of these — including
the receipt (see Unsubscribe).
# Announce immediately (idempotent — notified signups are never emailed twice)
php artisan jamesgifford:hold:announce --context=prelaunch
# See who would be emailed, without sending
php artisan jamesgifford:hold:announce --dry-runIf exactly one context has pending signups, --context can be omitted. The
announce job sends in chunks and stamps notified_at per recipient.
Delayed / auto-announce. With auto_announce_on_up enabled, disabling
prelaunch (or bringing the app back up) dispatches the announcement after
announce_delay_minutes. That delay is a change-of-mind window: if the same hold
is active again when the job runs, it aborts silently and emails no one.
⚠️ Auto-announce needs a real queue. Laravel'ssyncconnection runs jobs inline and throws the delay away, so the change-of-mind window would be zero and the mass email would go out — unrecallably — the instant the hold ended. Whenannounce_delay_minutesis above zero and the default queue connection issync, Hold refuses to dispatch, logs a warning, and tells you to runjamesgifford:hold:announceyourself. Setannounce_delay_minutesto0if you genuinely want an immediate send on a sync queue.You also need a worker actually running (
php artisan queue:work) for a delayed job to fire at all — Hold can check the connection, not your workers.
Every package email renders through a self-contained HTML template with inline styles and no dependency on Laravel's mail markdown layout (no theme, no build step). Setup publishes three, one per email, that you own and edit:
| Template | |
|---|---|
resources/views/vendor/hold/mail/announcement.blade.php |
launch & restore announcements |
resources/views/vendor/hold/mail/team.blade.php |
the internal "hold enabled" team notice |
resources/views/vendor/hold/mail/receipt.blade.php |
the optional signup receipt |
The package falls back to its own copy of each until you publish. Two levels of control:
1. Edit colors, header, and copy — edit the published template. Everything a developer changes lives in plain-PHP variable blocks at the very top of each file (email clients support CSS custom properties poorly, so these are interpolated into the inline styles). Colors — a five-value palette:
$bg = '#f5f6f8'; $card = '#ffffff'; $text = '#1a1d24'; $muted = '#6b7280'; $accent = '#2563eb';Copy — a $copy block holding every string. The announcement template keys
it by hold mode; edit the wording (and the button label/URL) in place:
$copy = [
'prelaunch' => ['heading' => 'We\'ve launched!', 'body' => '…', 'button' => 'Take a look', 'url' => config('app.url')],
'maintenance' => ['heading' => 'We\'re back!', 'body' => '…', 'button' => 'Return to the site', 'url' => config('app.url')],
];Subjects stay out of the templates so you can tweak them without
republishing a view — set notifications.subject_launch and
notifications.subject_restored in config.
Add a header (logo or wordmark). A third variable block (in every email template) just below the palette drives an optional header above the heading, with three modes:
$logoUrl = null; // mode A: absolute, publicly hosted image URL
$logoName = null; // mode B: text wordmark (e.g. config('app.name'))
$logoWidth = 150; // rendered image width in px- Image — set
$logoUrl. It must be an absolute, publicly hosted URL (email clients can't load local files); useasset('images/logo.png')or a CDN link. Any source size works: it renders centered at$logoWidthwith its aspect ratio preserved (no cropping). A wide/landscape logo (~3:1) suits the layout best, and for crisp high-DPI display point it at a source ~2–3× the rendered width (~300–450px wide for the default 150). Blocked-image clients fall back to the alt text. - Wordmark — leave
$logoUrlnull and set$logoNameto render the name as a styled masthead (tweak its look inline, right there in the file). - None (default) — leave both null; no header and no header spacing render.
If both are set the image wins; the name is still used as the image's alt text.
Existing installs: a template published before these features won't have the header or
$copyblocks. Re-publish it (delete your copy and re-run setup, orvendor:publish --tag=jamesgifford-hold-views --force) or hand-add the blocks from the package copy — there is no automatic merge.
2. Change structure or channels — replace the notification class. Point any
entry under notifications.classes at your own subclass to take over toMail()
entirely (a different template, extra channels, etc.) — the package resolves the
class name at send time:
// config/jamesgifford/hold.php
'notifications' => [
'classes' => [
'launch_announcement' => \App\Notifications\OurLaunch::class,
// ...
],
],Unsubscribe is a data contract, not a feature. The package keeps the
unsubscribed_at column and fully respects it — an unsubscribed row receives
no package email (announcements and the signup receipt) — but ships no
user-facing way to set it: no route, no controller, no link in any email. Your
app decides whether and how to expose opt-out (e.g. a future global
communications preference).
The package provides the means and nothing more:
-
Model methods on the published
App\Models\HoldSignup:->unsubscribe()and->resubscribe()(set / clearunsubscribed_at). -
Operator command (server-side only, no public exposure):
php artisan jamesgifford:hold:unsubscribe user@example.com php artisan jamesgifford:hold:unsubscribe user@example.com --resubscribe
The package never sets or clears unsubscribed_at on its own — not even on
re-arm. An unsubscribed address whose row is re-armed by a later signup succeeds
silently but is emailed nothing until the app resubscribes it.
Published to config/jamesgifford/hold.php. Key options:
| Key | Default | Purpose |
|---|---|---|
routes.register |
true |
Register the package routes. false = own routing (publish the routes stub). |
routes.prefix |
hold |
URI prefix for every package route. |
routes.middleware |
['web'] |
Middleware group for the routes. |
prelaunch.status_code |
200 |
HTTP status for the prelaunch page (200 or 503). |
prelaunch.bypass_cookie_name |
hold_bypass |
Name of the preview bypass cookie. |
prelaunch.bypass_cookie_lifetime_days |
30 |
Bypass cookie lifetime. |
notifications.team_addresses |
[] |
Who receives the "hold enabled" notice. |
notifications.send_signup_receipt |
false |
Email each new signup a receipt. |
notifications.auto_announce_on_up |
false |
Auto-schedule the announcement when a hold ends. |
notifications.announce_delay_minutes |
10 |
Change-of-mind delay before an auto-announce sends. |
notifications.subject_launch |
We're live! |
Subject of the launch announcement (body copy lives in the template). |
notifications.subject_restored |
We're back online |
Subject of the restore announcement (body copy lives in the template). |
mail.from.address / mail.from.name |
null |
From override (falls back to app defaults). |
spam.rate_limit_per_minute |
5 |
Per-IP signup rate limit. |
spam.honeypot_field |
website |
Hidden honeypot field name. |
models.signup |
App\Models\HoldSignup |
Resolved HoldSignup model. |
models.namespace / models.path |
App\Models / app/Models |
Where setup publishes the model (HoldSignup.php). |
jamesgifford:hold:setup publishes everything for you; these tags exist for
re-publishing a single asset group with vendor:publish:
| Tag | Publishes |
|---|---|
jamesgifford-hold-config |
config/jamesgifford/hold.php |
jamesgifford-hold-models |
app/Models/HoldSignup.php |
jamesgifford-hold-views |
the holding pages, email templates, and the errors/503.blade.php shim |
jamesgifford-hold-routes |
routes/hold.php (the self-hosted routes stub) |
Note vendor:publish --tag=jamesgifford-hold-models publishes the model
verbatim, without the namespace rewrite — use jamesgifford:hold:setup for
that. The migration is not a publish tag at all; setup owns it, because it needs
a fresh publish-time timestamp.
Set routes.register => false and publish the routes stub to wire routing
yourself:
php artisan vendor:publish --tag=jamesgifford-hold-routes
# then load routes/hold.php from your own provider or bootstrap/app.php:
# Route::middleware('web')->prefix('hold')->group(base_path('routes/hold.php'));Keep the prefix in sync with routes.prefix: the holding pages, the prelaunch
allow-list, and the maintenance except-merge all read that value to know which
URIs are "package routes".
Whatever models.signup points at must implement
JamesGifford\Hold\Contracts\HoldSignupContract — it declares unsubscribe(),
resubscribe(), and the columns the package reads. The published
App\Models\HoldSignup implements it out of the box (setup writes it in), and so
does any subclass of it. This is what lets the package resolve an app-owned class
it has no static relationship with; a configured class that exists but does not
implement the contract raises a clear exception rather than silently falling back
to the package's own model.
Every package view is published into Laravel's standard vendor-views location so you own an editable copy:
resources/views/vendor/hold/
├── prelaunch.blade.php
├── maintenance.blade.php
└── mail/
├── announcement.blade.php
├── team.blade.php
└── receipt.blade.php
How overriding works. The package registers these under the hold:: view
namespace, so a published copy in resources/views/vendor/hold/ automatically
overrides the package default — the package renders your copy when it exists
and its own otherwise. To revert a view to the shipped default, just delete the
published file; no config toggle is involved. (The one exception to the layout
is resources/views/errors/503.blade.php — Laravel dictates that path for the
maintenance response; it's a thin shim that renders hold::maintenance, so you
edit maintenance.blade.php, not the shim.)
The prelaunch and maintenance pages are self-contained single Blade files with inline CSS and no build step — they render even when your app is half-broken. Colors are four CSS custom properties at the top for a three-line reskin:
:root {
--hold-bg: #f5f6f8;
--hold-card-bg: #ffffff;
--hold-text: #1a1d24;
--hold-accent: #2563eb;
}Copy is a $copy block beside them holding every user-visible string —
title, heading, sub-text, the email field's label/placeholder, the button, the
privacy note, and both form-state messages (the success line shown after a
signup and the invalid line for a bad email). Both state messages live in the
block so you can reword them in one place without triggering them; edit any line
and it renders. Each page is self-contained, so the strings the two pages share
are duplicated per file by design — there is no shared copy include.
The email templates live alongside them under mail/ — see
Customizing the announcement emails for
their palette, header/logo, and copy model.
The package binds a subclass of Laravel's
Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance in the
container. Wherever Laravel resolves that framework middleware, it gets the
subclass, which merges the package's route URIs (built from routes.prefix) into
the maintenance except list — so the signup/preview routes stay
reachable during down.
This is just a container binding: no core file is modified, and
composer remove jamesgifford/hold removes it automatically. This is also why
down --render must not be used — it renders outside the HTTP kernel, so neither
the binding nor the signup route participate.
The package ships a Laravel Boost skill
(resources/boost/skills/jamesgifford-hold/) that teaches an AI assistant the
package's public API and guardrails — the two hold modes, signup capture, the
announcement commands, notification overrides, and anti-patterns (e.g. never
php artisan down --render). In a consuming app that uses Boost, install it with
php artisan boost:install (or boost:update to refresh).
php artisan jamesgifford:hold:uninstall # removes assets + drops the table
php artisan jamesgifford:hold:uninstall --keep-data # keeps the table + migration
composer remove jamesgifford/hold # finishes (removes the binding)Uninstall removes the published config, model, views, migration, and the runtime
storage directory, then drops the hold_signups table (with its own confirmation;
--keep-data skips the drop and leaves the migration in place). Setup → uninstall
round-trips to a clean state.
Dropping the table is always an explicit act. If the run can't ask — -n /
--no-interaction, as CI and deploy scripts use — the published assets are still
removed but the table and its migration are kept, and the command says so.
Pass --force to drop without being asked. In production, uninstall refuses
outright without --force.
| Command | Flags | Purpose |
|---|---|---|
jamesgifford:hold:setup |
--force, --migrate |
Publish config, migration, model, views; optionally migrate. |
jamesgifford:hold:uninstall |
--force, --keep-data |
Remove everything published and drop the table (--keep-data to keep it). |
jamesgifford:hold:enable {mode} |
— | Activate a hold — prelaunch or maintenance (refuses if one is already active). |
jamesgifford:hold:disable |
— | Deactivate whichever hold is active; optionally auto-announce. |
jamesgifford:hold:announce |
--context, --dry-run |
Email the launch/restore announcement. |
jamesgifford:hold:unsubscribe {email} |
--resubscribe |
Operator tool: set or clear a signup's unsubscribe state. |
Neither enable nor disable is production-guarded — they are the normal way to
put a live site into and out of a hold. setup and uninstall are guarded, and
refuse to run in production without --force.
One command runs every gate — code style, static analysis, and the suite against both engines:
composer install
composer checkThe individual gates, if you want them separately:
| Command | What it does |
|---|---|
composer lint |
Pint, check only |
composer format |
Pint, apply fixes |
composer analyse |
PHPStan level 6 (no baseline — findings get fixed, not recorded) |
composer test |
The suite against MariaDB |
composer test:sqlite |
The suite against SQLite |
composer test:parallel |
The suite in parallel (see note below) |
The suite defaults to MariaDB, because that is the deployment target and some
invariants only exist there — notably that requested_at never acquires an
implicit ON UPDATE CURRENT_TIMESTAMP on a server with
explicit_defaults_for_timestamp=0. SQLite cannot express that, so running only
on SQLite would mean asserting it rather than testing it.
composer test:parallel gives each worker its own schema and its own storage
tree, because prelaunch mode's source of truth is a flag file — without both,
workers read each other's holds and the run produces false failures. The
per-worker schemas are dropped automatically when the run finishes. On a suite
this size it is not meaningfully faster than serial; it exists to prove the
suite has no hidden shared state.
Point it at your own server with DB_HOST / DB_PORT / DB_DATABASE /
DB_USERNAME / DB_PASSWORD (defaults: 127.0.0.1:3306, database hold_test,
user root, password root). DB_CONNECTION=sqlite skips the MariaDB-only
tests and needs no server. CI runs both.
MIT © James Gifford