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:
- 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);
}
}
- 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:
-
The
ensureEnvironmentVariablesAreAvailable()
method now uses Laravel'sconfig()
helper to access the S3 credentials instead of checking$_ENV
directly. -
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
.