leorossi/laravel-image-s3

Upload images to S3/R2/local disk and generate configurable size/quality variants for Laravel.

Maintainers

Package info

github.com/leorossi/laravel-image-s3

pkg:composer/leorossi/laravel-image-s3

Transparency log

Statistics

Installs: 6

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.2.0 2026-08-06 15:27 UTC

This package is auto-updated.

Last update: 2026-08-06 15:34:15 UTC


README

A lightweight Laravel package to upload images to S3-compatible storage (AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces) or a local disk, and automatically generate configurable size/quality variants.

Requirements

  • PHP ^8.2
  • Laravel ^11.0 | ^12.0 | ^13.0
  • GD extension (used by Intervention Image for processing)

Installation

composer require leorossi/laravel-image-s3

Publish the config file:

php artisan vendor:publish --tag=image-s3-config

Run migrations:

php artisan migrate

The migration is registered automatically. If you would rather own it — for example because you run migrations with an explicit --path or --database — publish it and turn the automatic registration off:

php artisan vendor:publish --tag=image-s3-migrations
// config/image-s3.php
'register_migrations' => false,

Leaving it on while also publishing gives you two migrations creating the same table, so set it to false whenever you publish.

Configuration

The published config file lives at config/image-s3.php.

return [
    'disk' => env('IMAGE_S3_DISK', 's3'),

    'connection' => env('IMAGE_S3_CONNECTION'),

    'original_directory' => 'original',
    'variants_directory' => 'variants',

    'path_prefix' => env('IMAGE_S3_PATH_PREFIX'),

    'register_migrations' => env('IMAGE_S3_REGISTER_MIGRATIONS', true),

    'queue' => [
        'enabled' => env('IMAGE_S3_QUEUE_ENABLED', false),
        'connection' => env('IMAGE_S3_QUEUE_CONNECTION', null),
        'queue' => env('IMAGE_S3_QUEUE_NAME', 'default'),
    ],

    'variants' => [
        'thumbnail' => [
            'width' => 150,
            'height' => 150,
            'quality' => 80,
            'fit' => 'cover',   // crop to exact size
        ],
        'medium' => [
            'width' => 800,
            'height' => 600,
            'quality' => 85,
            'fit' => 'contain', // fit inside dimensions
        ],
        'large' => [
            'width' => 1920,
            'height' => 1080,
            'quality' => 90,
            'fit' => 'contain',
        ],
    ],
];

Adding custom variants

You can add as many variants as you want. Each variant needs:

  • width — target width in pixels
  • height — target height in pixels
  • quality — JPEG/WebP quality (0–100)
  • fitcover (crop to exact dimensions) or contain (fit inside dimensions)

Usage

Upload an image

use LeoRossi\LaravelImageS3\Facades\ImageS3;

$upload = ImageS3::upload($request->file('photo'), 'My photo alt text');

$upload->original_url;
$upload->variantUrl('thumbnail');
$upload->srcset();

Using the service directly

use LeoRossi\LaravelImageS3\Contracts\ImageUploaderInterface;

public function __construct(private ImageUploaderInterface $uploader) {}

$upload = $this->uploader->upload($request->file('photo'));

Queue variant generation

Set queue.enabled to true to process variants asynchronously. The original image is stored immediately; a ProcessImageVariants job is dispatched to generate variants.

Delete an upload

Deleting the model also removes the original and all variants from storage:

$upload->delete();

Or use the facade/service:

ImageS3::delete($upload);

Multi-tenancy

Every option below is read at the moment it is used, not cached when the container first resolves the service. Changing them from tenant middleware mid-request works, and the shared uploader singleton will not hand a previous tenant's settings to the next request in a long-running worker.

Database connection

Set connection to run the ImageUpload model against a tenant database:

config()->set('image-s3.connection', 'tenant');

null (the default) uses your application's default connection. The model overrides getConnectionName(), so the value is resolved on every query — a connection swap after a model has already been instantiated is still respected.

The package migration is not connection-aware on purpose: it obeys php artisan migrate --database=tenant, so your tenant provisioning stays in charge of where the table is created.

Storage disk

image-s3.disk is read per upload, so pointing tenants at different buckets is just:

config()->set('image-s3.disk', 'tenant-bucket');

Path prefix

To keep tenants in separate directories on a shared disk, set path_prefix. It is prepended to both originals and variants, so uploads land in {prefix}/original/… and {prefix}/variants/{name}/….

// A plain string
'path_prefix' => 'tenants/42',

// An invokable class, resolved from the container on every write
'path_prefix' => \App\Support\TenantPathPrefix::class,

// A closure
'path_prefix' => fn () => 'tenants/'.tenant('id'),
namespace App\Support;

class TenantPathPrefix
{
    public function __invoke(): string
    {
        return 'tenants/'.tenant('id');
    }
}

Using php artisan config:cache? A closure in a config file cannot be serialised and will break the cache command. Use the invokable class-string form instead — it is cache-safe and resolved just as late.

Full paths are stored on each ImageUpload row, so changing or removing the prefix later does not orphan existing uploads: reads and deletes keep using the path that was recorded at upload time.

S3 / Cloudflare R2 Setup

Laravel does not include the S3 Flysystem adapter by default. Install it first:

composer require league/flysystem-aws-s3-v3

Configure your disk as usual in config/filesystems.php:

'r2' => [
    'driver' => 's3',
    'key' => env('CLOUDFLARE_R2_ACCESS_KEY_ID'),
    'secret' => env('CLOUDFLARE_R2_SECRET_ACCESS_KEY'),
    'region' => 'auto',
    'bucket' => env('CLOUDFLARE_R2_BUCKET'),
    'endpoint' => env('CLOUDFLARE_R2_ENDPOINT'),
    'use_path_style_endpoint' => true,
    'visibility' => 'public',
],

Then set IMAGE_S3_DISK=r2 in your .env.

For standard AWS S3 you do not need endpoint; leave AWS_ENDPOINT empty. AWS_URL is only needed when you want generated URLs to use a custom domain such as CloudFront.

Automated AWS S3 bucket setup

A helper script is included to create the S3 bucket, an IAM user, a least-privilege policy and an access key:

./scripts/setup-s3-bucket.sh my-unique-bucket-name eu-central-1

Requirements:

  • AWS CLI installed and configured with permissions to create S3 buckets, IAM users, access keys and policies.
  • jq installed.

The script is fully non-interactive and prints a ready-to-paste .env snippet when it finishes. For R2, MinIO or DigitalOcean Spaces you must create the bucket and credentials through their own tools.

To remove everything later, run the teardown script:

./scripts/teardown-s3-bucket.sh

It reads the bucket and IAM user from example-app/.env, refuses to delete a non-empty bucket unless you pass --force, and removes the bucket, IAM user, access keys and policy.

Testing

The package ships with PHPUnit tests using a local testing disk:

composer install
vendor/bin/phpunit

License

MIT