Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Wagonwheel Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.
  • The published migration — confirms which columns exist (uuid/id, subject, HTML body, timestamps). Decide now whether you want opaque IDs.
  • The package's 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.

Implementation Patterns

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:

  • Public marketing mail: leave web only, but switch the model to UUIDs so links are unguessable. Consider adding signed so URLs can't be replayed or edited.
  • Transactional/account mail: add 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.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource