kiora / sulu-s3-bundle
S3 storage integration for Sulu CMS with Garage compatibility (S3 without ACL support)
Package info
github.com/kiora-tech/sulu-s3-bundle
Type:symfony-bundle
pkg:composer/kiora/sulu-s3-bundle
Requires
- php: >=8.3
- aws/aws-sdk-php: ^3.0
- league/flysystem: ^3.0
- league/flysystem-aws-s3-v3: ^3.5
- psr/log: ^3.0
- sulu/sulu: ^3.0
- symfony/config: ^7.1 || ^8.0
- symfony/console: ^7.1 || ^8.0
- symfony/dependency-injection: ^7.1 || ^8.0
- symfony/framework-bundle: ^7.1 || ^8.0
- symfony/http-kernel: ^7.1 || ^8.0
- symfony/yaml: ^7.1 || ^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.0
- league/flysystem-memory: ^3.0
- phpstan/phpstan: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^11.0 || ^12.0
This package is auto-updated.
Last update: 2026-07-31 05:56:34 UTC
README
A Symfony bundle for S3 storage integration with Sulu CMS, specifically designed to work with Garage and other S3-compatible storage that doesn't implement ACL operations.
Features
- Garage S3 Compatibility: Works with Garage and other S3-compatible storage without ACL support
- Streaming Mode: Files served via PHP instead of public URL redirects (required for private buckets)
- Local Caching: Crash-safe local cache, invalidated automatically when a media is removed or moved
- Auto-configuration: Automatically uses S3 in production/staging, local storage in development
- Fail-fast configuration: A missing endpoint, bucket or credential is reported at build time, not on the first upload
- Flexible Configuration: Easy setup via environment variables or YAML configuration
Requirements
- PHP 8.3 or higher
- Symfony 7.1 or higher
- Sulu CMS 3.x
- An S3-compatible storage (AWS S3, Garage, MinIO, etc.)
Installation
1. Install via Composer
composer require kiora/sulu-s3-bundle
2. Enable the Bundle
If not using Symfony Flex, add the bundle to config/bundles.php:
return [ // ... KioraTech\SuluS3Bundle\SuluS3Bundle::class => ['all' => true], ];
3. Configure Environment Variables
Add the following to your .env file:
###> kiora/sulu-s3-bundle ### S3_ENDPOINT=https://s3.garage.example.com S3_BUCKET=your-bucket-name S3_REGION=garage S3_ACCESS_KEY=your-access-key S3_SECRET_KEY=your-secret-key ###< kiora/sulu-s3-bundle ###
4. (Optional) Create Bundle Configuration
Create config/packages/sulu_s3.yaml for custom configuration:
sulu_s3: enabled: true environments: - prod - stage s3: endpoint: '%env(S3_ENDPOINT)%' bucket: '%env(S3_BUCKET)%' region: '%env(S3_REGION)%' access_key: '%env(S3_ACCESS_KEY)%' secret_key: '%env(S3_SECRET_KEY)%' use_path_style_endpoint: true prefix: '' streaming: enabled: true temp_dir: '%kernel.cache_dir%/sulu-media' cache_ttl: 3600 fallback: enabled: true path: '%kernel.project_dir%/var/storage/default'
Configuration Reference
Root Options
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | true |
Enable/disable the S3 integration |
environments |
array | ['prod', 'stage'] |
Environments where S3 storage is active |
adapter_service |
string | flysystem.adapter.default.storage |
Flysystem adapter service replaced by the bundle. Must match flysystem.adapter. + your sulu_media.storage.flysystem_service |
S3 Options (sulu_s3.s3)
| Option | Type | Default | Description |
|---|---|---|---|
endpoint |
string | %env(S3_ENDPOINT)% |
S3 endpoint URL |
bucket |
string | %env(S3_BUCKET)% |
S3 bucket name |
region |
string | %env(S3_REGION)% |
S3 region |
access_key |
string | %env(S3_ACCESS_KEY)% |
S3 access key |
secret_key |
string | %env(S3_SECRET_KEY)% |
S3 secret key |
use_path_style_endpoint |
boolean | true |
Use path-style URLs (required for Garage/MinIO) |
prefix |
string | '' |
Prefix for all S3 keys |
version |
string | 'latest' |
AWS SDK version |
default_visibility |
public/private |
public |
Visibility reported for every object, since ACLs are never queried |
strip_acl |
boolean | true |
Remove ACL parameters from every request. Required for Garage and MinIO without ACL |
connect_timeout |
float | 5.0 |
Connection timeout in seconds (0 to disable) |
timeout |
float | 30.0 |
Request timeout in seconds (0 to disable) |
max_retries |
integer | 3 |
Retries performed by the AWS SDK on transient failures |
Streaming Options (sulu_s3.streaming)
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | true |
Enable streaming mode (files served via PHP) |
temp_dir |
string | sys_get_temp_dir()/sulu-media |
Temporary directory for cached files |
cache_ttl |
integer | 3600 |
Cache TTL in seconds (0 to disable) |
dir_permissions |
integer | 0o700 |
Permissions of the cache directory |
file_permissions |
integer | 0o600 |
Permissions of the cached files |
The cache directory holds copies of possibly private media. The default permissions keep it readable by the PHP user only. Widen them only if your cleanup cron runs under a different user.
Fallback Options (sulu_s3.fallback)
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | true |
Register a local adapter outside the S3 environments, when none is defined |
path |
string | %kernel.project_dir%/var/storage/default |
Local storage path |
How It Works
GarageS3Adapter
The GarageS3Adapter extends Flysystem's AwsS3V3Adapter to bypass ACL operations that Garage doesn't support:
GetObjectAcl- Returns a fixed visibility instead of querying S3PutObjectAcl- Skipped, relies on bucket default permissions- Grant options (
GrantRead,GrantFullControl, …) are filtered out of every request
NoAclVisibilityConverter
The NoAclVisibilityConverter implements Flysystem's VisibilityConverter interface without using ACL:
visibilityToAcl()- Returns empty string (no ACL set)aclToVisibility()- Returns default visibility without checking grants
An empty ACL is not enough on its own: the AWS SDK turns it into an empty
x-amz-acl header rather than omitting it. The bundle therefore also installs a
middleware on the S3 client that drops the ACL parameter from every outgoing
command, so the header is never sent. Set strip_acl: false if your backend does
support ACLs and you want the standard behaviour back.
StreamingFlysystemStorage
The StreamingFlysystemStorage extends Sulu's FlysystemStorage to serve files via PHP:
- Always returns
TYPE_LOCALto prevent URL redirects - Downloads files from S3 to a local temp directory
- Caches files for a configurable TTL to reduce S3 requests
- Downloads go to a unique part file that is then renamed into place, so an interrupted or concurrent download can never serve a truncated file
- Removing or moving a media drops its cached copy immediately, instead of serving a stale file until the TTL expires
Garage S3 Setup
1. Create a Bucket
garage bucket create your-bucket-name
2. Create API Keys
garage key create sulu-app garage bucket allow your-bucket-name --read --write --key sulu-app
3. (Optional) Enable Website Access
If you want direct public access (without streaming):
garage bucket website your-bucket-name --allow
Note: Even with website access enabled, this bundle uses streaming mode by default for better compatibility.
Migrating Existing Media
If you have existing media in local storage, you can migrate it to S3:
# Using AWS CLI aws s3 sync var/storage/default s3://your-bucket-name/ \ --endpoint-url=https://s3.garage.example.com # Or using rclone rclone sync var/storage/default garage:your-bucket-name/
Cache Cleanup
The bundle caches S3 files locally. Expired entries are never removed on their own, so schedule the shipped command:
# Preview what would be removed bin/console sulu:s3:cleanup-cache --dry-run # Actually remove the expired entries bin/console sulu:s3:cleanup-cache # Ignore the configured TTL for this run bin/console sulu:s3:cleanup-cache --ttl=86400
A typical crontab entry:
0 * * * * /usr/bin/php /var/www/app/bin/console sulu:s3:cleanup-cache --env=prod
The command exits successfully and prints a warning when streaming storage is not active, so it is safe to schedule in every environment.
Troubleshooting
Error: "Failed to retrieve the ACL"
This error occurs when using standard S3 adapters with Garage. The bundle's GarageS3Adapter fixes this by bypassing ACL operations.
Error: "The Flysystem adapter service … does not exist"
The bundle replaces the adapter service Sulu reads its media from. Sulu builds
that name as flysystem.adapter. followed by
sulu_media.storage.flysystem_service (default.storage by default). If you
changed it, mirror the change in sulu_s3.adapter_service.
Error: "Access Denied" when accessing media
Ensure your Garage bucket has proper permissions:
garage bucket allow your-bucket-name --read --key sulu-app
Files not updating after upload
Media removed or moved through Sulu drop their cached copy automatically. For
anything changed directly on the bucket, either wait for the TTL, lower
cache_ttl, or clear the cache:
bin/console sulu:s3:cleanup-cache --ttl=0
Local development not working
The bundle uses local storage in non-production environments by default. Ensure sulu_s3.fallback.enabled is true and the fallback path exists. Note that the
fallback never overwrites an adapter you defined yourself: outside the S3
environments, your own flysystem configuration wins.
Development
make install # install the dev dependencies make test # run the test suite make phpstan # run the static analysis (level 9) make cs # check the coding standards make ci # run every quality gate used in CI
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
License
This bundle is released under the MIT License. See the LICENSE file for details.