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

Presigned Url Laravel Package

tattali/presigned-url

Generate temporary signed URLs so clients can securely access private files for a limited time. Ideal for S3-compatible or local storage uploads and downloads without exposing credentials or making buckets public. Simple API for Laravel and plain PHP.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and publish config

composer require tattali/presigned-url
php artisan vendor:publish --tag=presigned-url-config   # confirm the tag in the README

Check the generated config/presigned-url.php first — that's where the default TTL and any signing customization live. Read the config file before the README; it's the most accurate description of what the package actually does.

2. Register the validation middleware

Laravel 11+ (bootstrap/app.php):

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'presigned' => \Tattali\PresignedUrl\Http\Middleware\ValidatePresignedUrl::class,
    ]);
})

Laravel 10 and below: add the same alias to app/Http/Kernel.php $routeMiddleware.

3. First use case: an expiring download link

The package's sweet spot is "give this link to someone who isn't logged in, and make it die in 15 minutes." Start there rather than with uploads — it's the smallest surface area and easiest to verify end-to-end.

// routes/web.php
Route::get('/downloads/{document}', DocumentDownloadController::class)
    ->middleware('presigned')
    ->name('downloads.show');
// generating the link
$url = PresignedUrl::route('downloads.show', ['document' => $document->id], now()->addMinutes(15));

Exact facade/helper names should be confirmed against the README — but the shape is "named route + parameters + expiry," and that's all you need to get a link into an email or a redirect response.

Where to look first when something breaks: the middleware. Every failure mode (expired, tampered, scheme mismatch, clock skew) surfaces there as a 403, so start by logging inside it or by temporarily removing it to confirm the route itself works.


Implementation Patterns

Wrap generation in a single service

You will almost certainly want to switch to Laravel's built-in URL::temporarySignedRoute() at some point. Isolate the calls now:

final class LinkSigner
{
    public function download(Document $doc, ?CarbonInterface $expires = null): string
    {
        return PresignedUrl::route(
            'downloads.show',
            ['document' => $doc->id],
            $expires ?? now()->addMinutes(config('presigned-url.ttl', 15)),
        );
    }
}

One file to change if the package is abandoned or you outgrow it.

Per-link expiry, not just config

Config gives you a sane default; most real features need at least two tiers. Invitations want days, download links want minutes, API callbacks want seconds. Pass expiry explicitly at every call site and treat the config value as a fallback only.

Pair generation and validation in the same feature slice

A signed URL is only as good as the middleware guarding its route. It's easy to add the generator and forget the middleware, resulting in a link that works forever and accepts any tampering. Make it a checklist item in code review: every route whose URL is signed must have the middleware, and every route with the middleware must only be reachable via a signed URL.

Guard against the inverse too — a controller that trusts {document} from the URL path. The signature proves the URL wasn't tampered with, not that the current user is entitled to that record beyond what the signature covers.

Direct-to-storage: redirect, don't proxy

If the underlying motivation is offloading bytes to S3, the pattern is a signed Laravel route that validates then 302s to a short-lived storage URL:

public function __invoke(Document $document)
{
    return redirect()->away(
        Storage::disk('s3')->temporaryUrl($document->path, now()->addMinutes(5))
    );
}

Two short-lived links chained together. It's an extra hop, but it keeps the storage URL out of your emails and out of browser history.

Route parameters as the payload

Anything the signature needs to cover should be a route parameter, not a hidden session value or a database lookup keyed on something unsigned. /downloads/{document} is right; /downloads?token=abc where abc maps to a record in your DB is a second, weaker system layered on top of this one.

Queued mail and notifications

Generating the URL at send time, inside the job, is usually correct — a URL generated when the job was dispatched may already be expired when it runs. If you must pre-generate (e.g. a digest containing many links), size the TTL against your worst-case queue latency, not your happy path.

Testing

Freeze time and prove both directions:

$this->travel(16)->minutes();
$this->get($url)->assertForbidden();   // expired

And assert the tampered case explicitly — flip a character in the signature and expect a 403. That test is the entire value proposition of the package; if it doesn't pass, the middleware isn't wired up.


Gotchas and Tips

The name is misleading. This is route signing — the same idea as Laravel's signed middleware — not AWS SDK presigned PUT/POST policies. If you need browser-to-S3 direct uploads with policy conditions, Storage::temporaryUrl() plus UploadedFile validation covers reads, and a real presigned POST needs the AWS SDK. Verify which one you're getting before you design a feature around it.

Storage::temporaryUrl() only works on the S3 driver. Local and FTP disks fly an exception. If you're on S3-compatible storage (MinIO, R2, Wasabi), confirm the endpoint and path-style config work with the URL signer — R2 in particular has historically needed care.

APP_KEY rotation kills every outstanding link. Laravel's native signed URLs derive from the app key; if this package does the same, rotating the key in .env invalidates every unexpired link you've ever sent. Rotate deliberately, and expect support tickets.

Behind a proxy or load balancer, scheme/host mismatch breaks signatures. If the signing host is http://internal-host and the request arrives as https://app.example.com, validation fails with no obvious cause. Configure TrustProxies and set URL::forceScheme('https') in a service provider before you spend an afternoon debugging 403s.

Clock skew between app servers. Expiry checks are wall-clock comparisons. On multiple instances with drifting clocks, links expire early on some nodes. Keep NTP configured; don't set TTLs of 30 seconds thinking they'll be precise.

Signed URLs leak through the usual channels. Query strings land in access logs, Referer headers, analytics, and Slack unfurls. The signature prevents tampering, not disclosure — anyone holding the link has access until it expires. Set Referrer-Policy: no-referrer on pages containing these links, and never encode sensitive data (emails, names, internal IDs you wouldn't put in a URL) into route parameters.

Long-running downloads can outlive the check. Middleware validates at request start; a slow download continues past expiry. For large files that's usually acceptable, but don't set a 60-second TTL on a 2 GB export and expect it to be enforced.

Run php artisan config:cache after config edits. Standard Laravel trap, but it bites hardest on a package whose whole behavior is config-driven — you'll change the TTL and see no effect.

Supply chain. Zero dependents, a single maintainer, and a release date that looks anomalous. Pin the version in composer.json, wrap it in the service class above, and don't adopt it on a security-critical path without reading the middleware source — it's short enough to read in ten minutes, and you should, because that's the code standing between an attacker and your files.

Middleware ordering matters. The presigned check must run before anything that would leak information or perform side effects — particularly before route-model binding resolves expensive relationships, and definitely before authorization middleware that could return a different status code and reveal whether an ID exists.

Extension point worth knowing: if you need revocation before expiry (a "kill this link now" button), this package can't do it — signatures are self-contained. The pattern is to include a token_version or revoked_at route parameter that the middleware-adjacent code checks against the database, which means the signature is no longer your only gate. Decide early whether "expires eventually" is good enough, because retrofitting revocation changes the URL shape and breaks every link already issued.

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.
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment