sammyjo20/wagonwheel
Wagonwheel is an open-source Laravel package by Sammy Jo that provides a clean, developer-friendly API for adding practical functionality to Laravel applications. It installs easily, follows Laravel conventions, and helps keep your codebase simple and maintainable.
Wagonwheel persists a rendered copy of a Laravel Mailable and exposes it over HTTP so recipients can read it in a browser — the classic "View this email in your browser" link, backed by your own database instead of an ESP.
Install and scaffold
composer require sammyjo20/wagonwheel
php artisan vendor:publish --provider="Sammyjo20\Wagonwheel\WagonwheelServiceProvider"
php artisan migrate
Publish both the config and the migration (the provider exposes separate tags — check php artisan vendor:publish output for wagonwheel-config and wagonwheel-migrations) and run migrate to create the table that holds the stored HTML plus subject/metadata.
Where to look first
config/wagonwheel.php — route prefix, middleware stack, expiry window, table name. Read this before touching anything else; it determines the URL shape and who can open it.uuid/id, subject, HTML body, timestamps). Decide now whether you want opaque IDs.routes/ and controller — tells you exactly what the public endpoint does, how it resolves the record, and what it returns on a miss/expiry.Sammyjo20\Wagonwheel\Facades\Wagonwheel — the entry point you'll call from application code.First use case: order confirmation
use Sammyjo20\Wagonwheel\Facades\Wagonwheel;
use Illuminate\Support\Facades\Mail;
$mailable = new OrderShipped($order);
// Persist the rendered version first...
$webVersion = Wagonwheel::create($mailable);
$url = $webVersion->url(); // absolute URL built from route + APP_URL
// ...then send the mail that links to it.
Mail::to($order->user)->send(
(new OrderShipped($order))->withWebUrl($url)
);
In the Mailable's Blade view (or a shared layout partial):
@if ($webUrl ?? false)
<a href="{{ $webUrl }}">Having trouble viewing this email? View it in your browser.</a>
@endif
That is the whole loop: render → store → link. Everything below is about doing it safely and reliably at scale.
Separate the "create" step from the "send" step. Generate the web version in an action class or queued job, then dispatch the Mailable. Doing it inside the Mailable's build() is tempting but the mailable runs again on every queue retry, and you'll end up with duplicate rows and different URLs on retry.
class SendOrderShipped implements ShouldQueue
{
public function handle(): void
{
$web = Wagonwheel::create(new OrderShipped($this->order));
Mail::to($this->order->user)->send(new OrderShipped($this->order, $web->url()));
}
}
Pass the URL into the Mailable, don't reach back into the DB from the view. Add a nullable ?string $webViewUrl constructor argument and expose it via a view variable. This keeps the Mailable testable and avoids coupling your templates to Wagonwheel's models.
Gate the route with middleware in config. Two common shapes:
web only, but switch the model to UUIDs so links are unguessable. Consider adding signed so URLs can't be replayed or edited.auth (or a custom can:view,wagonwheelEmail gate) and authorize on the stored email's recipient. Anything with invoices, addresses, or reset-style tokens should never rely on obscurity alone.// config/wagonwheel.php
'middleware' => ['web', 'auth'],
Add a unique key for idempotency. If your job can retry, add a mail_key (e.g. order:1234:shipped) and wrap creation in firstOrCreate, or a unique index. Otherwise retries leave orphan HTML blobs.
Prune on a schedule. HTML email bodies are big. Add a scheduled command:
Schedule::command('model:prune', ['--model' => [WagonwheelEmail::class]])->daily();
with Prunable on the model (or a manual where('created_at', '<', now()->subDays(30))->delete()). Align the retention window with the expires_after config so links die after the body is gone rather than 404-ing from a stale row.
Roll your own route when you need authorization you can't express in middleware. Disable/ignore the package route and add a controller action that looks up the record, authorizes against the authenticated user, and returns the stored HTML. This is the escape hatch for multi-tenant apps and for injecting your app layout around the email.
Reuse a shared Blade partial. Put the "view in browser" banner in resources/views/vendor/mail/html/header.blade.php (or your layout), guarded by @isset($webUrl), so every Mailable gets the link without per-template work.
It is archived — plan for the exit. Last release Feb 2021, targeting Laravel 8-era APIs. It will not install cleanly on Laravel 9+ without constraint changes, and no one is patching it. Pin the version in composer.json, and if you're on a newer framework, budget for a fork or a ~100-line hand-rolled replacement (emails table + controller + signed route) — the package's surface area is small enough that the replacement is often cheaper than the workaround.
The stored HTML is the full email, including anything sensitive. Tokens, invoice totals, addresses, password-reset links. The default route is not authenticated. Audit what your Mailables actually render before exposing them, and prefer UUID route keys over auto-increment IDs so nobody enumerates /wagonwheel/1..n.
Assets are not automatically inlined. If you render via view()->render() yourself, you get raw <link> tags and remote images — the browser view will look broken behind corporate image proxies and won't match the inbox rendering. Let Wagonwheel render the Mailable so Laravel's mail CSS inliner runs. Even then, images hosted on your CDN remain external; that's usually fine but note it in QA.
APP_URL leaks into stored URLs. url() is resolved at creation time. In a queue worker with a misconfigured APP_URL (or a multi-domain app), links will point at localhost or the wrong tenant. Set the root URL explicitly in the job if you serve multiple hosts: URL::forceRootUrl($tenant->mail_host) before calling Wagonwheel::create().
Config and route caching. After changing the prefix or middleware, run php artisan config:cache (and route:cache if you cache routes). The package route participates in route caching, so a prefix change without a re-cache silently keeps serving the old path.
Expiry returns HTTP errors, not friendly pages. If expires_after is set, expired links 404/403. Make sure the email copy and any support runbook say "this link expires in N days," and consider overriding the package's exception handling to render a branded fallback.
Model serialization bites queued Mailables. If you pass an Eloquent model (or a WagonwheelEmail) into a Mailable that gets queued, it's serialized. Store the plain URL string, not the model instance, or you'll hit ModelNotFoundException when the queue picks it up after a deletion or prune.
Testing. There's no first-class fake() helper here. Assert against the DB and the route instead:
$this->assertDatabaseCount('wagonwheel_emails', 1);
$this->get($webVersion->url())->assertOk()->assertSee($order->reference);
Use RefreshDatabase and freeze time if you're asserting expiry behaviour.
Disable it where it doesn't belong. Wrap route registration or gate the middleware in local/staging if you're storing real customer mail — an open index of rendered transactional emails is a data-protection incident waiting to happen.
How can I help you explore Laravel packages today?