tattali / presigned-url
S3-style presigned URLs for any storage backend
Requires
- php: >=8.2
Requires (Dev)
- aws/aws-sdk-php: ^3.0
- friendsofphp/php-cs-fixer: ^3.40
- illuminate/config: ^12.44
- illuminate/container: ^12.44
- illuminate/http: ^10.0|^11.0|^12.0
- illuminate/routing: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
- infection/infection: ^0.32.6 || ^0.35.8
- league/flysystem: ^3.0
- phpstan/phpstan: ^1.10|^2.0
- phpunit/phpunit: ^10.5|^11.0
- psr/log: ^1.0|^2.0|^3.0
- symfony/config: ^6.4|^7.0|^8.0
- symfony/dependency-injection: ^6.4|^7.0|^8.0
- symfony/http-foundation: ^6.4|^7.0|^8.0
- symfony/http-kernel: ^6.4|^7.0|^8.0
- symfony/routing: ^6.4|^7.0|^8.0
Suggests
- aws/aws-sdk-php: Required for native S3 presigned URLs
- illuminate/support: Required for Laravel integration
- league/flysystem: Required for Flysystem adapter
- psr/log: Required for access logging
- symfony/http-foundation: Required for Symfony integration
- symfony/http-kernel: Required for Symfony bundle
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v1.1.0
- v1.0.1
- v1.0.0
- dev-release-notes-for-the-next-version
- dev-changelog-from-tags
- dev-credibility-pass
- dev-fix-signing-hardening
- dev-signature-tests-and-mutation-testing
- dev-dependabot-and-weekly-ci
- dev-test-php-8.5
- dev-fix-leading-slash-path
- dev-fix-gzip-correctness
- dev-use-the-file-extension-when-local-mime-detection-is-generic
- dev-fix-laravel-head-route
- dev-fix-range-requests
- dev-fix-content-safety-headers
This package is auto-updated.
Last update: 2026-10-24 11:47:51 UTC
README
S3-style presigned URLs for any storage backend.
Installation
composer require tattali/presigned-url
Features
- Presigned URL generation with HMAC signature (timing-safe, secret of at least 32 bytes)
- Multi-bucket support with different adapters
- Conditional caching (ETag, If-None-Match, If-Modified-Since -> 304)
- Range requests (206 Partial Content, 416 for unsatisfiable ranges,
If-Range) - Gzip compression when the client accepts it
- Configurable CORS
X-Content-Type-Options: nosniffon every file, and HTML, SVG, XML and JavaScript files sent as sandboxed attachmentsLocalAdapterrejects..segments and symbolic links that lead outside the bucket directory- File extension validation
- Symfony 6.4/7.x/8.x and Laravel 12 integrations (CI does not install Laravel 10 or 11)
- Zero core dependencies (PHP 8.2+)
Standalone Usage
<?php use Tattali\PresignedUrl\Config\Config; use Tattali\PresignedUrl\Factory\StorageFactory; // Configuration. The secret must be at least 32 bytes: generate one with bin2hex(random_bytes(32)) $config = new Config( secret: 'change-me-to-a-random-32-byte-secret', baseUrl: 'https://cdn.example.com', ); // Create storage and server [$storage, $server] = StorageFactory::createWithServer($config); // Add a bucket with a local adapter $storage->addBucket('invoices', StorageFactory::localAdapter('/var/storage/invoices')); // Generate a presigned URL (expires in 1 hour) $url = $storage->temporaryUrl('invoices', 'invoice-2024.pdf', 3600); // https://cdn.example.com/invoices/invoice-2024.pdf?X-Expires=1234567890&X-Signature=abc123... // Or with a DateTime $url = $storage->temporaryUrl('invoices', 'invoice-2024.pdf', new \DateTimeImmutable('+1 hour'));
Serving Files
// In your controller or entry point $response = $server->serve( bucket: 'invoices', path: 'invoice-2024.pdf', expires: (int) $_GET['X-Expires'], signature: $_GET['X-Signature'], method: $_SERVER['REQUEST_METHOD'], headers: getallheaders(), ); // Send the response $response->send();
Adapters
LocalAdapter
Local filesystem storage. Paths with .. segments, and paths that lead outside the bucket directory through symbolic links, are rejected.
use Tattali\PresignedUrl\Factory\StorageFactory; $adapter = StorageFactory::localAdapter('/var/storage/files'); $storage->addBucket('documents', $adapter);
FlysystemAdapter
Wrapper for League Flysystem.
use League\Flysystem\Filesystem; use League\Flysystem\Local\LocalFilesystemAdapter; use Tattali\PresignedUrl\Factory\StorageFactory; $filesystem = new Filesystem(new LocalFilesystemAdapter('/var/storage')); $adapter = StorageFactory::flysystemAdapter($filesystem); $storage->addBucket('documents', $adapter);
AwsS3Adapter
Uses native S3 presigned URLs.
use Tattali\PresignedUrl\Factory\StorageFactory; $adapter = StorageFactory::s3Adapter([ 'key' => 'AKIAIOSFODNN7EXAMPLE', 'secret' => 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', 'region' => 'eu-west-1', 'bucket' => 'my-bucket', 'endpoint' => null, // Optional, for S3-compatible services (MinIO, etc.) ]); $storage->addBucket('s3-files', $adapter); // Generated URLs will be native S3 presigned URLs $url = $storage->temporaryUrl('s3-files', 'document.pdf', 3600);
Advanced Configuration
use Tattali\PresignedUrl\Config\Config; use Tattali\PresignedUrl\Config\SignatureConfig; use Tattali\PresignedUrl\Config\ServingConfig; use Tattali\PresignedUrl\Config\SecurityConfig; use Tattali\PresignedUrl\Config\CompressionConfig; $config = new Config( secret: 'change-me-to-a-random-32-byte-secret', baseUrl: 'https://cdn.example.com', // Signature configuration signature: new SignatureConfig( algorithm: 'sha256', length: 16, expiresParam: 'X-Expires', signatureParam: 'X-Signature', ), // Serving configuration serving: new ServingConfig( defaultTtl: 3600, maxTtl: 86400, cacheControl: 'private, max-age=3600, must-revalidate', contentDisposition: 'inline', // or 'attachment' compression: new CompressionConfig( enabled: true, minSize: 1024, level: 6, types: ['text/css', 'application/javascript', 'application/json'], ), allowUnsafeInline: false, // true serves HTML, SVG, XML and JavaScript with contentDisposition too ), // Security configuration security: new SecurityConfig( allowedExtensions: [], // Empty = all allowed (except blocked) blockedExtensions: ['php', 'exe', 'sh', 'bat'], maxFileSize: 0, // 0 = unlimited allowedOrigins: ['https://example.com'], // For CORS ), );
Symfony Integration
Bundle Configuration
// config/bundles.php return [ // ... Tattali\PresignedUrl\Bridge\Symfony\PresignedUrlBundle::class => ['all' => true], ];
# config/packages/presigned_url.yaml presigned_url: secret: '%env(PRESIGNED_URL_SECRET)%' base_url: '%env(PRESIGNED_URL_BASE)%' signature: algorithm: sha256 length: 16 expires_param: X-Expires signature_param: X-Signature serving: default_ttl: 3600 max_ttl: 86400 cache_control: 'private, max-age=3600, must-revalidate' content_disposition: inline allow_unsafe_inline: false compression: enabled: true min_size: 1024 level: 6 types: - text/css - application/javascript - application/json security: allowed_extensions: [] blocked_extensions: [php, exe, sh, bat] max_file_size: 0 allowed_origins: [] buckets: invoices: adapter: local path: '%kernel.project_dir%/var/storage/invoices' documents: adapter: flysystem service: 'default.storage' # Flysystem service s3_files: adapter: s3 key: '%env(AWS_ACCESS_KEY_ID)%' secret: '%env(AWS_SECRET_ACCESS_KEY)%' region: '%env(AWS_DEFAULT_REGION)%' bucket: '%env(AWS_BUCKET)%'
Routes
# config/routes/presigned_url.yaml presigned_url_serve: path: /storage/{bucket}/{path} controller: Tattali\PresignedUrl\Bridge\Symfony\Controller\ServeController requirements: path: .+ methods: [GET, HEAD]
Usage in a Controller
use Tattali\PresignedUrl\Storage\StorageInterface; class InvoiceController { public function __construct( private StorageInterface $storage, ) {} public function download(string $invoiceId): Response { $url = $this->storage->temporaryUrl( 'invoices', sprintf('%s.pdf', $invoiceId), new \DateTimeImmutable('+1 hour'), ); return new RedirectResponse($url); } }
Laravel Integration
Publishing Configuration
php artisan vendor:publish --tag=presigned-url-config
Configuration
// config/presigned-url.php return [ 'secret' => env('PRESIGNED_STORAGE_SECRET', env('APP_KEY')), 'base_url' => env('PRESIGNED_STORAGE_URL', env('APP_URL') . '/storage/serve'), 'signature' => [ 'algorithm' => 'sha256', 'length' => 16, 'expires_param' => 'X-Expires', 'signature_param' => 'X-Signature', ], 'serving' => [ 'default_ttl' => 3600, 'max_ttl' => 86400, 'cache_control' => 'private, max-age=3600, must-revalidate', 'content_disposition' => 'inline', 'allow_unsafe_inline' => false, 'compression' => [ 'enabled' => true, 'min_size' => 1024, 'level' => 6, 'types' => ['text/css', 'application/javascript', 'application/json'], ], ], 'security' => [ 'allowed_extensions' => [], 'blocked_extensions' => ['php', 'exe', 'sh', 'bat'], 'max_file_size' => 0, 'allowed_origins' => [], ], 'buckets' => [ 'invoices' => [ 'adapter' => 'local', 'path' => storage_path('app/invoices'), ], ], ];
Usage with the Facade
use Tattali\PresignedUrl\Bridge\Laravel\Facades\PresignedUrl; // Generate a presigned URL $url = PresignedUrl::temporaryUrl('invoices', 'invoice.pdf', 3600); // Or with dependency injection use Tattali\PresignedUrl\Storage\StorageInterface; class InvoiceController extends Controller { public function download(StorageInterface $storage, string $id) { $url = $storage->temporaryUrl('invoices', "{$id}.pdf", 3600); return redirect($url); } }
Routes
Routes are automatically registered:
GET /storage/serve/{bucket}/{path}->presigned-url.serve(also answersHEAD)
URL Format
/{bucket}/{path}?X-Expires={timestamp}&X-Signature={signature}
Example:
https://cdn.example.com/invoices/2024/invoice-001.pdf?X-Expires=1704067200&X-Signature=05322bd54b5348554216f098c328a885
FileServer Features
Conditional Caching
The server supports HTTP cache headers:
ETag: File hash for validationIf-None-Match: Returns 304 if the file hasn't changedIf-Modified-Since: Returns 304 if not modified since the date
Range Requests
Support for partial requests for streaming:
GET /bucket/video.mp4
Range: bytes=0-1023
HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1023/1048576
Content-Length: 1024
- One range per request, in the forms
bytes=0-1023,bytes=1024-(to the end) andbytes=-500(the last 500 bytes). An end past the end of the file is cut to the file size. - A range that starts after the end of the file,
bytes=-0and any range on an empty file get416 Range Not SatisfiablewithContent-Range: bytes */<size>. - Other values (several ranges, a first byte after the last one such as
bytes=5-3, another unit) are ignored and the whole file is sent with 200. - With
If-Range, the range is applied only when the value is exactly the file'sETagorLast-Modified. Otherwise the whole file is sent with 200. - Partial responses are never compressed.
Compression
FileServer gzips the body when all of these hold:
- compression is enabled;
- the file is at least
min_sizebytes; - its MIME type is exactly one of the
types(a type with parameters, such astext/plain; charset=utf-8, does not match); - the request's
Accept-Encodingacceptsgzip(orx-gzip, or*) with a quality above 0.
The response then carries Content-Encoding: gzip, the compressed Content-Length and an ETag ending in -gzip. Every file that could be compressed is sent with Vary: Accept-Encoding, so caches keep both versions apart. A HEAD request or a 304 response describes the version that GET would send; HEAD then has no Content-Length, because the compressed size is not computed.
The whole file is read and compressed in memory. For large text files, disable compression and let the web server compress responses.
Content Safety
Every file is sent with X-Content-Type-Options: nosniff, so browsers use the Content-Type and do not guess another one.
HTML, SVG, XML and JavaScript files can run script in the browser. They are recognised by extension (html, htm, xhtml, svg, svgz, xml, xsl, js, mjs) or by media type (HTML, XML, +xml, JavaScript and multipart/ types, and any Content-Type that is not a single valid type). These files are always sent with Content-Disposition: attachment and Content-Security-Policy: sandbox, whatever content_disposition says, so a file uploaded by a user cannot run script on your domain. An SVG shown with <img src="..."> still displays.
To serve them with content_disposition instead, set allow_unsafe_inline to true. Do it only when every file in the buckets is trusted, or when the files are served from a separate, cookieless domain.
The file name in Content-Disposition is cleaned: control characters are removed, and ", \, %, / and non-ASCII characters are replaced with _ in filename. When the name had to change and is valid UTF-8, the original name is added as filename*=UTF-8''... (RFC 6266), which browsers use.
CORS
When the request has an Origin header that is allowed, it is echoed back with these headers. An empty allowed_origins list (the default) allows every origin, so set it to restrict CORS:
Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Methods: GET, HEAD
Access-Control-Allow-Headers: Range
Access-Control-Expose-Headers: Content-Length, Content-Range, Accept-Ranges
Exceptions
| Exception | Description |
|---|---|
PresignedUrlException |
Base exception |
BucketNotFoundException |
Bucket not found |
FileNotFoundException |
File not found |
InvalidPathException |
Path traversal detected |
InvalidSignatureException |
Invalid signature |
ExpiredUrlException |
URL expired |
InvalidBucketNameException |
Bucket name rejected by Storage::addBucket() |
InvalidConfigurationException |
Secret under 32 bytes, signature length out of bounds or unknown algorithm. Extends InvalidArgumentException, not PresignedUrlException |
HTTP Response Codes
| Code | Description |
|---|---|
| 200 | File served successfully |
| 206 | Partial content (range request) |
| 304 | Not modified (valid cache) |
| 400 | Invalid request |
| 403 | Invalid signature |
| 404 | File or bucket not found |
| 410 | URL expired |
| 416 | Range not satisfiable |
Security Model
This section describes what the code does. File names refer to src/. To report a vulnerability, see SECURITY.md.
What the signature covers
Signer/HmacSigner.php signs the string {bucket}:{path}:{expires}:
bucket: the bucket name, the first segment of the URL path;path: the rest of the URL path, as passed totemporaryUrl()without its leading slashes;expires: the expiry as a Unix timestamp, theX-Expiresquery parameter.
Nothing else is signed: not the HTTP method, the host or base URL, other query parameters, request headers such as Range or Origin, or response headers such as Content-Disposition.
Storage::temporaryUrl() removes leading slashes from the path before it signs it and builds the URL, so /invoice.pdf and invoice.pdf give the same URL.
Bucket names are validated by Storage::addBucket() (3 to 63 characters, lowercase letters, digits and single hyphens), so they cannot contain : and the signed string is unambiguous. If you call addBucket($name, $adapter, validate: false), do not use : in bucket names.
Algorithm
hash_hmac()withSignatureConfig::$algorithm,sha256by default. The algorithm must be one ofhash_hmac_algos(); the name is case-insensitive.- The binary digest is hex-encoded and cut to
SignatureConfig::$lengthbytes: 16 bytes by default, so 32 hexadecimal characters and 128 bits.lengthmust be at least 16 (SignatureConfig::MIN_LENGTH) and at most the digest size (32 forsha256, 64 forsha512). - One secret per
Config, shared by every bucket. - The secret must be at least 32 bytes (
HmacSigner::MIN_SECRET_LENGTH, counted withstrlen()). Generate one withbin2hex(random_bytes(32)). Config,HmacSignerandSignatureConfigthrowInvalidConfigurationException(anInvalidArgumentException) when they are built with a shorter or empty secret, alengthout of these bounds or an unknown algorithm. The message never contains the secret. In the Symfony bundle this happens when the service is first built.- The Laravel configuration falls back to
APP_KEYwhenPRESIGNED_STORAGE_SECRETis not set. Set a dedicated secret.
Signature comparison
HmacSigner::verify() recomputes the signature, rejects a signature whose length differs from the expected one, and compares it with hash_equals(), which takes the same time whatever the position of the first differing character. The comparison is case-sensitive: the signature must be lowercase hex.
FileServer checks the signature before anything else (FileServer::doServe()). An unsigned request to serve() gets 403 whether or not the bucket, the file or the expiry are valid.
Expiry
Storage::temporaryUrl()with an integer TTL setsexpires = time() + min($ttl, maxTtl).maxTtlis 86400 seconds by default.- With a
DateTimeInterface, its timestamp is used as is.maxTtlis not applied. ServingConfig::$defaultTtlis not used by the core classes.FileServer::validateExpiration()rejects a request with 410 whentime() > expires, so a URL is valid up to and including its expiry second.- The expiry is signed, so it cannot be changed without the secret. The server does not apply
maxTtlto incoming URLs: a URL signed with a far expiry stays valid until then. - Both checks use the local clock. Servers that generate and serve URLs need synchronised clocks.
FileServer::serveFromRequest()answers 400, before it checks the signature, whenX-Expiresis missing, zero or not a number, orX-Signatureis missing.serve(), which the Symfony and Laravel controllers call, takes these values as arguments and answers 403 for them.
Paths
LocalAdapterrejects any..segment withInvalidPathException(400). It also drops empty and.segments and treats\as a separator.- It then resolves the full path with
realpath()(for a missing file, its deepest existing parent directory) and checks that the result is the bucket directory or below it, with a path separator after the bucket path, so a sibling directory such asfiles-oldnext tofilesdoes not match. Symbolic links are followed only when their target stays inside the bucket directory. A link that leads outside it givesInvalidPathException(400). The resolved path is the one that is opened, and paths with a NUL byte are rejected. - The check and the opening of the file are two steps. Someone who can replace a directory with a symbolic link between them can still escape the bucket. Do not let untrusted users create symbolic links in a bucket directory.
FlysystemAdapterpasses the path to Flysystem, whose path normaliser rejects traversal above the root. Flysystem's exceptions are not converted to an HTTP status byFileServer.AwsS3Adapteruses the path as an S3 key.- Paths are inserted into the URL without percent-encoding. Keep object names to characters that are safe in a URL path (no
?,#or%).
Extensions and sizes
FileServer::validateExtension()takes the last extension only (pathinfo()), lowercased. Blocked or not allowed extensions answer 404.- The default block-list is
php,phtml,php3,php4,php5,php7,phps,phar,exe,sh,batandcmd. Block-list entries must be lowercase. - When
allowedExtensionsis not empty, only those extensions are served. Files without an extension skip both lists, even when an allow-list is set. - When
maxFileSizeis above 0, larger files answer 404. - The extension says nothing about the content. The
Content-Typecomes from the adapter:LocalAdapterdetects it from the file content withfinfo. Whenfinfoonly gives a generic type (text/plain,application/octet-stream, an empty-file type, or a source-code guess such astext/x-c++ortext/x-java),LocalAdapteruses the extension for common web and text files (.js,.mjs,.css,.svg,.json,.html,.xml,.csv,.woff2and a few others), so browsers accept scripts and styles despitenosniff. Any other type detected from the content wins over the extension.
What is not protected
- Replay. Anyone who has a URL can use it any number of times until it expires. URLs are not bound to a user, a session or an IP address.
- Revocation. A single URL cannot be revoked. Changing the secret invalidates all of them.
- Method. GET and HEAD share the same signature.
- Host. The URL works on any host that serves the same bucket with the same secret.
- Leaks. The signature is in the query string, so it ends up in access logs, browser history and
Refererheaders. - Scriptable files with
allow_unsafe_inline. When it istrue, HTML, SVG, XML and JavaScript files are served withcontent_dispositionand withoutContent-Security-Policy: sandbox, so their scripts run on the serving domain. - Other inline content. Files that are not recognised as scriptable (see Content Safety) are displayed inline with the default
content_disposition. Detection relies on the extension and on theContent-Typereported by the adapter. If buckets hold files uploaded by users, also considercontent_disposition: attachmentor a separate, cookieless domain. - Uploaded scripts and stylesheets.
.jsand.cssfiles are served with their real type, so a page on any origin can load them with<script src>or as a stylesheet:Content-Disposition: attachmentandContent-Security-Policy: sandboxdo not prevent that. When buckets hold files uploaded by users and are served from your application's domain, ascript-src 'self'policy does not stop an uploaded script. Addjs,mjs,cjsandcsstoblocked_extensions(it applies to every bucket), or serve the files from a separate domain. - CORS. With an empty
allowedOriginslist (the default), anyOriginis echoed back inAccess-Control-Allow-Origin. - Native S3 URLs. For
AwsS3Adapterbuckets the client downloads directly from S3, so the extension and size checks, content safety headers, CORS settings and logging ofFileServerdo not apply. Access is governed by S3 and your IAM permissions. - Confidentiality. The HMAC proves that a URL was issued with the secret. It does not encrypt anything: serve over HTTPS.
- Rate limiting. There is none.
Rotating the secret
Rotation is not supported out of the box. HmacSigner holds one secret and URLs carry no key identifier, so changing the secret makes every outstanding URL fail with 403.
To rotate without breaking issued URLs, implement SignerInterface so that it signs with the new secret and also accepts the old one, then remove the old secret once the longest-lived URL signed with it has expired (maxTtl for integer TTLs):
use Tattali\PresignedUrl\Signer\HmacSigner; use Tattali\PresignedUrl\Signer\SignerInterface; final class RotatingSigner implements SignerInterface { /** @param list<HmacSigner> $previous */ public function __construct( private HmacSigner $current, private array $previous = [], ) {} public function sign(string $bucket, string $path, int $expires): string { return $this->current->sign($bucket, $path, $expires); } public function verify(string $bucket, string $path, int $expires, string $signature): bool { foreach ([$this->current, ...$this->previous] as $signer) { if ($signer->verify($bucket, $path, $expires, $signature)) { return true; } } return false; } } $signer = new RotatingSigner( new HmacSigner($newSecret, $config->signature), [new HmacSigner($oldSecret, $config->signature)], ); [$storage, $server] = StorageFactory::createWithServer($config, $signer);
With the Symfony bundle, decorate the Tattali\PresignedUrl\Signer\SignerInterface service. With Laravel, extend the SignerInterface binding in a service provider.
Compatibility with AWS S3
The URLs that this library signs itself (LocalAdapter, FlysystemAdapter and custom adapters) are not AWS Signature Version 4 URLs:
- they carry
X-ExpiresandX-Signatureand none of theX-Amz-*parameters; - the signature is an HMAC of
bucket:path:expires, not of a SigV4 canonical request with a credential scope, region, service and signed headers; X-Expiresis an absolute Unix timestamp, whereas S3'sX-Amz-Expiresis a duration in seconds.
S3, S3-compatible services and AWS SDKs cannot validate these URLs. Only this library's FileServer (directly or through the Symfony and Laravel controllers) can.
For AwsS3Adapter buckets, temporaryUrl() does not use this format at all. It returns the URL built by the AWS SDK's S3Client::createPresignedRequest() for a GetObject command (Adapter/AwsS3Adapter.php). With the SDK's default settings this is a SigV4 query-string URL (X-Amz-Algorithm=AWS4-HMAC-SHA256, X-Amz-Credential, X-Amz-Date, X-Amz-Expires, X-Amz-SignedHeaders=host, X-Amz-Signature), signed with your AWS credentials and checked by S3.
- The SDK refuses SigV4 expirations longer than one week (604800 seconds) with an
InvalidArgumentException. The defaultmaxTtlkeeps integer TTLs below that, but aDateTimeInterfacefurther than a week ahead fails. - S3-compatible services reached through the
endpointoption must accept SigV4 presigned URLs. - Only downloads (
GetObject) are presigned. Uploads are not supported.
Why not Flysystem temporaryUrl() / native S3 presigning?
- Native S3 presigning only exists for objects stored in S3 or an S3-compatible service. When every file is in S3, calling the AWS SDK directly is enough.
AwsS3Adapterdoes exactly that. - Flysystem 3's
temporaryUrl()delegates to the adapter or to aTemporaryUrlGeneratoryou configure, and throwsUnableToGenerateTemporaryUrlotherwise. The local adapter has no generator: you would have to write the signing and the endpoint that checks the URL and serves the file. - This library gives one
temporaryUrl()call for local, Flysystem and S3 buckets. For non-S3 buckets it also provides the serving side: signature and expiry checks, ETag andLast-Modifiedhandling (304), range requests and CORS, with Symfony and Laravel controllers. FlysystemAdapternever uses Flysystem's owntemporaryUrl(), so a Flysystem bucket backed by S3 is streamed through PHP. UseAwsS3Adapterfor S3 so that clients download from S3 directly.
Tests
composer install vendor/bin/phpunit
Mutation Testing
Infection mutates the signing, URL and verification code (src/Signer, src/Storage, src/Server) and checks that the tests catch each change. It needs a coverage driver (PCOV or Xdebug).
vendor/bin/infection --threads=max
Static Analysis
vendor/bin/phpstan analyse
Code Formatting
vendor/bin/php-cs-fixer fix
License
MIT License. See LICENSE for details.