kiora/sulu-s3-bundle

S3 storage integration for Sulu CMS with Garage compatibility (S3 without ACL support)

Maintainers

Package info

github.com/kiora-tech/sulu-s3-bundle

Type:symfony-bundle

pkg:composer/kiora/sulu-s3-bundle

Transparency log

Statistics

Installs: 173

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-07-31 05:56 UTC

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 S3
  • PutObjectAcl - 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_LOCAL to 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.

Credits