ticketpark/expiring-url-bundle
A Symfony bundle for generating expiring URLs, letting you create time-limited, signed links to protect routes and actions. Configure expiration windows and validate signatures before granting access.
First, the key fact: this is a Symfony bundle, not a Laravel package. There is no service provider, no config publish tag, no facade. If you're in a Laravel codebase you will not composer require this and get anything useful — Laravel already ships URL::temporarySignedRoute() / URL::signedRoute() and the signed middleware, which cover the same ground. Read this package as a reference implementation of the algorithm and a drop-in for the Symfony services you maintain, then port the ~40 lines of hashing logic if you actually need it in Laravel.
Assuming a Symfony app:
composer require ticketpark/expiring-url-bundle
Register the bundle in config/bundles.php (Symfony Flex usually does this), then configure the secret — do not let it default to something derivable:
# config/packages/ticketpark_expiring_url.yaml
ticketpark_expiring_url:
secret: '%env(EXPIRING_URL_SECRET)%'
Generate a real secret (php -r "echo bin2hex(random_bytes(32));") and store it outside the repo. The bundle's whole security model is "HMAC over the URL + expiry timestamp with this secret."
Where to look first: run bin/console debug:container ticketpark to get the actual service ID, and read the generator + validator classes in vendor/ticketpark/expiring-url-bundle/. They are short. Confirm the exact method names before writing code — the public surface is essentially two calls (create a URL with a TTL, validate an incoming URL), and the signatures have shifted between versions.
First use case: a time-limited download link. A controller action mints a URL for /files/42/download valid for 30 minutes, emails it, and a second action validates before streaming the file. Everything else (invites, password resets, preview links, signed webhooks) is the same shape.
Generate at the edge, validate at the boundary. Inject the service where the URL is produced, not where it's consumed, and keep the validator in a single place — a listener, a middleware-equivalent kernel.request subscriber, or an explicit check at the top of the protected action.
// generating
public function share(ExpiringUrlService $expiringUrl): Response
{
$url = $expiringUrl->generate(
$this->router->generate('file_download', ['id' => 42], RouterInterface::ABSOLUTE_URL),
new \DateInterval('PT30M')
);
// mail it, return it as JSON, whatever
}
// validating — explicit, at the top of the protected action
public function download(Request $request)
{
if (!$this->expiringUrl->isValid($request->getUri())) {
throw new GoneHttpException('This link has expired.');
}
// stream the file
}
Return 410 Gone for expired links rather than 403 — it's semantically correct, it stops browsers and mail scanners from retrying, and it makes logs readable.
Prefer a request subscriber for anything with more than one protected route. A single subscriber that matches a route attribute or a path prefix beats copy-pasting the check into five controllers, and it means a forgotten controller fails closed only if you invert the logic (deny by default, allow-list the exceptions).
Bind identity into the URL, not the session. The signature covers the URL and the expiry only. If a link must belong to a specific user or file, put the user/file ID in the path so it's inside the signed payload, and re-check authorization after validating — a valid signature proves the link was minted by you, not that the current visitor is entitled to it.
Laravel equivalent, for comparison:
$url = URL::temporarySignedRoute('files.download', now()->addMinutes(30), ['file' => 42]);
// in the route
Route::get('/files/{file}/download', ...)->middleware('signed');
If you have a mixed estate, standardize on this rather than porting the bundle.
Twig/Blade helper: wrap generation in a small service method so templates never construct TTLs inline; a link_expiring(path, ttl) function keeps the 30-minute policy in one place when legal asks you to change it to 15.
http vs https, and www vs apex all break the hash. Generate with ABSOLUTE_URL and validate against $request->getUri(), and make sure your reverse proxy passes the original scheme/host through (trusted_proxies + X-Forwarded-* in Symfony) or you'll generate links that never validate in production while working fine locally.EXPIRING_URL_SECRET you need a grace period with two secrets (validate against both, sign with the new one) or you'll break every in-flight email. Plan it.hash_equals(), never ===. The bundle should already do this — verify it before trusting it.?, port number). bin/console debug:router to confirm the route actually produces the path you think it does.How can I help you explore Laravel packages today?