Fixing Laravel Vapor S3 Uploads After Migration Away from Vapor

When migrating away from Laravel Vapor while still using direct-to-S3 uploads with the laravel-vapor npm package, you might encounter issues with file uploads failing. This typically happens because Vapor's signed storage URL functionality expects certain environment variables to be available in $_ENV, which aren't automatically populated from your .env file in traditional hosting environments like Forge or Ploi.

The Problem in Detail

The issue occurs because Laravel Vapor's SignedStorageUrlController specifically checks for environment variables using $_ENV instead of Laravel's config() helper. In a Vapor environment, these variables are automatically injected into $_ENV, but in traditional hosting environments, they're typically only accessible through the config() helper.

This means that even if you have properly configured your S3 credentials in your .env file:

AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
AWS_DEFAULT_REGION=your-region
AWS_BUCKET=your-bucket

The Vapor storage URL signing process will fail because it can't find these variables in $_ENV.

The Solution

To fix this, we need to create a custom controller that extends Vapor's SignedStorageUrlController and modifies how it accesses the AWS credentials.

Here's how to implement it:

  1. First, create a new controller in app/Http/Controllers/SignedStorageUrlController.php:

This controller extends the default SignedStorageUrlController (see source) and overrides the store() , ensureEnvironmentVariablesAreAvailable() and storageClient() methods to use Laravel's config() helper to access the S3 credentials instead of the $_ENV (php.net docs).

<?php

namespace App\Http\Controllers;

use Aws\S3\S3Client;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use InvalidArgumentException;
use Laravel\Vapor\Http\Controllers\SignedStorageUrlController as VaporSignedStorageUrlController;

class SignedStorageUrlController extends VaporSignedStorageUrlController
{
    public function store(Request $request)
    {
        $this->ensureEnvironmentVariablesAreAvailable($request);

        Gate::authorize('uploadFiles', [
            $request->user(),
            $bucket = $request->input('bucket') ?: config('filesystems.disks.s3.bucket'),
        ]);

        $client = $this->storageClient();

        $uuid = (string) Str::uuid();

        $expiresAfter = config('vapor.signed_storage_url_expires_after', 5);

        $signedRequest = $client->createPresignedRequest(
            $this->createCommand($request, $client, $bucket, $key = $this->getKey($uuid)),
            sprintf('+%s minutes', $expiresAfter)
        );

        $uri = $signedRequest->getUri();

        return response()->json([
            'uuid' => $uuid,
            'bucket' => $bucket,
            'key' => $key,
            'url' => $uri->getScheme().'://'.$uri->getAuthority().$uri->getPath().'?'.$uri->getQuery(),
            'headers' => $this->headers($request, $signedRequest),
        ], 201);
    }

    protected function createCommand(Request $request, S3Client $client, $bucket, $key)
    {
        return $client->getCommand('putObject', array_filter([
            'Bucket' => $bucket,
            'Key' => $key,
            'ACL' => $request->input('visibility') ?: $this->defaultVisibility(),
            'ContentType' => $request->input('content_type') ?: 'application/octet-stream',
            'CacheControl' => $request->input('cache_control') ?: null,
            'Expires' => $request->input('expires') ?: null,
        ]));
    }

    protected function ensureEnvironmentVariablesAreAvailable(Request $request)
    {
        $missing = collect([
            'AWS_BUCKET' => $request->input('bucket') ?: config('filesystems.disks.s3.bucket'),
            'AWS_DEFAULT_REGION' => config('filesystems.disks.s3.region'),
            'AWS_ACCESS_KEY_ID' => config('filesystems.disks.s3.key'),
            'AWS_SECRET_ACCESS_KEY' => config('filesystems.disks.s3.secret'),
        ])->filter(function ($value) {
            return empty($value);
        })->keys();

        if ($missing->isEmpty()) {
            return;
        }

        throw new InvalidArgumentException(
            'Unable to issue signed URL. Missing environment variables: '.$missing->implode(', ')
        );
    }

    protected function storageClient()
    {
        $config = [
            'region' => config('filesystems.disks.s3.region', 'eu-west-1'),
            'version' => 'latest',
            'signature_version' => 'v4',
            'use_path_style_endpoint' => config('filesystems.disks.s3.use_path_style_endpoint', false),

        ];

        if (! isset($_ENV['AWS_LAMBDA_FUNCTION_VERSION'])) {
            $config['credentials'] = array_filter([
                'key' => config('filesystems.disks.s3.key') ?? null,
                'secret' => config('filesystems.disks.s3.secret') ?? null,
            ]);

            if (config()->has('filesystems.disks.s3.url') && ! is_null(config('filesystems.disks.s3.url'))) {
                $config['url'] = config('filesystems.disks.s3.url');
                $config['endpoint'] = config('filesystems.disks.s3.url');
            }
        }

        return new S3Client($config);
    }
}
  1. Register the controller in your app/Providers/AppServiceProvider.php:
use App\Http\Controllers\SignedStorageUrlController;
use Laravel\Vapor\Contracts\SignedStorageUrlController as SignedStorageUrlControllerContract;

public function register()
{
    $this->app->bind(SignedStorageUrlControllerContract::class, SignedStorageUrlController::class);
}

How It Works

The custom controller makes two key changes:

  1. The ensureEnvironmentVariablesAreAvailable() method now uses Laravel's config() helper to access the S3 credentials instead of checking $_ENV directly.

  2. The storageClient() method is modified to properly configure the S3 client using the credentials from your config, including support for custom endpoints if configured.

This allows your application to continue using the laravel-vapor npm package for direct-to-S3 uploads without requiring the variables to be in $_ENV.