friendsofcat/laravel-better-temporary-urls
A Laravel package for better temporary URLs: generate signed, expiring links for routes and files with a cleaner API, configurable lifetimes, and sensible security defaults. A drop-in upgrade for Laravel's built-in temporary URL helpers.
2.0.0 is the Laravel 9 line; projects on Laravel 8 or older must stay on ^0.5. Composer will not stop you from resolving a bad pair, so constrain explicitly: composer require friendsofcat/laravel-better-temporary-urls:^2.0.php artisan vendor:publish --tag=temporary-urls (confirm the tag in the README — this package has never had a large docs surface, so treat the tag name as unverified until you see the output).src/. The entire value of the package is the fluent builder, so skim it before the README — the method names are the API, and they are short enough to read in one screen.URL::temporarySignedRoute() where you also need to attach context that isn't a route parameter. A typical day-one usage:use FriendsOfCat\TemporaryUrl\Facades\TemporaryUrl;
$url = TemporaryUrl::route('downloads.show', ['download' => $download])
->withParameters(['user' => $user->id]) // extra query context
->validFor(30) // minutes
->generate();
Then hand $url to a notification, a redirect, or an API response. The receiving route keeps using Laravel's standard signed middleware — the package only mints URLs, it does not validate them.
Wrap it, don't sprinkle it. Expiry policy is a security decision and it drifts fast if every controller picks its own TTL. Put a tiny factory in your app — app/Support/TemporaryLinks.php — that owns the TTLs and the parameter shape, and inject the package there. When someone asks "how long are download links valid", there is one answer in one file.
Generate URLs late, especially in queued mail. This is the single most common production bug with any signed-URL package. If you build the URL in the controller and pass it into a ShouldQueue mailable, the clock starts at dispatch, not at delivery. Build it inside toMail() (or a Mailable::build() that runs on the queue worker) so a backlog doesn't hand out expired links.
Split minting from validating. Minting happens in trusted code paths (controllers, jobs, console commands). Validation stays on the route:
Route::get('downloads/{download}', DownloadController::class)
->middleware('signed')
->name('downloads.show');
Use signed:relative only if you deliberately want relative URLs; the default absolute-URL check is stricter and worth keeping.
Freeze time in tests. Signed-URL expiry is time-dependent and otherwise only fails in production. Use Carbon::setTestNow() and assert both sides of the boundary: one request before expiry returns 200, one after returns 403. Also test that a URL built with tampered parameters returns 403 — that is the assertion that proves your extra parameters are actually covered by the signature.
Migrate incrementally from URL::temporarySignedRoute(). Both produce URLs that validate under the same signed middleware, because the signature covers the full URL including query string. That means per-route rollout is safe: swap one call site, deploy, watch, continue.
Reach for it in the same places you'd reach for signed URLs generally: local-disk download links that must not be guessable, invite/onboarding links, one-click unsubscribe, short-lived preview URLs for unpublished content. Anything where "authenticated user" is too coarse a check.
0.5.0 — a major bump for a one-line changelog almost always means dropped support (Laravel ≤8 and/or PHP <8.0 in composer.json) and possibly signature/API adjustments. There is no upgrade guide. Read composer.json and git diff 0.5.0..2.0.0 before upgrading, and run your signed-URL feature tests as the gate rather than trusting a green composer update.URL::temporarySignedRoute() in an afternoon if you ever need to.http_build_query() a second time changes the string the signature was computed over and yields a 403. Generate once, store the whole string.APP_URL / proxy mismatches cause the classic "works locally, broken in prod." The signature is computed on the absolute URL. Behind a load balancer, if TrustProxies isn't configured, Laravel sees http://internal-host while the browser uses https://example.com, and signed rejects everything. If signed fails in staging only, check this before you check the package.APP_KEY. A rolling deploy that reads a rotated key, or any multi-service setup sharing routes but not keys, will invalidate live links mid-flight. Rotate keys with that in mind.URL::temporarySignedRoute() and compare the two strings. If they differ, it's a URL-assembly difference — usually encoding of route parameters rather than anything in the package. If they match and it still 403s, the problem is in your middleware, proxies, or key, not here.How can I help you explore Laravel packages today?