aws/aws-sdk-php
The AWS SDK for PHP (v3) lets PHP developers access Amazon Web Services like S3, DynamoDB, and Glacier. Install via Composer as aws/aws-sdk-php; requires PHP 8.1+ and cURL for building robust cloud applications.
Install and wire it once.
composer require aws/aws-sdk-php
Laravel already pulls this in transitively if you use the s3 filesystem disk or the ses/ses-v2 mail transport (league/flysystem-aws-s3-v3 depends on it), so composer show aws/aws-sdk-php first — you may already have it.
Put credentials in config/services.php, never call env() outside config files (config caching will silently return null):
// config/services.php
'aws' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
// omit key/secret entirely on EC2/ECS/Lambda and let the credential
// chain (instance profile / task role) do the work
],
Bind a configured client in a service provider so the container reuses one HTTP stack:
// app/Providers/AppServiceProvider.php
$this->app->singleton(S3Client::class, fn ($app) => new S3Client([
'version' => 'latest',
'region' => config('services.aws.region'),
'credentials' => array_filter([
'key' => config('services.aws.key'),
'secret' => config('services.aws.secret'),
]) ?: false, // false => fall back to the default provider chain
'http' => ['connect_timeout' => 2, 'timeout' => 15],
'retries' => 3,
]));
Where to look first: the User Guide for concepts, and the API docs for the exact parameter names of each operation — the API docs are the real reference; the guide only teaches shapes.
First use case: the one thing you'll do in nearly every Laravel app is put a user upload into S3 and hand out a URL. Two paths:
// Path A — you're already using the filesystem abstraction; no SDK code needed
Storage::disk('s3')->put("avatars/{$user->id}.jpg", $contents, 'private');
$url = Storage::disk('s3')->temporaryUrl("avatars/{$user->id}.jpg", now()->addMinutes(10));
// Path B — you need SDK features the Storage facade doesn't expose
$s3->putObject([
'Bucket' => config('filesystems.disks.s3.bucket'),
'Key' => "avatars/{$user->id}.jpg",
'Body' => fopen($path, 'r'),
'ContentType' => 'image/jpeg',
'ServerSideEncryption' => 'AES256',
]);
Reach for Path B only when you need multipart control, object tagging, SelectObjectContent, Glacier restore, cross-account assume-role, or non-S3 services (SQS, DynamoDB, SES v2, Lambda, CloudWatch, Polly).
One client per service, resolved from the container. Don't new S3Client() inside a controller or job. Singletons preserve Guzzle's connection pool and keep config in one place. For multiple services make a small factory or a AwsClientFactory class; for multi-tenant apps that must assume a role per customer, build clients from StsClient::assumeRole() and cache the resulting credentials (the SDK's CredentialProvider + Aws\CacheInterface handles refresh for you).
Prefer the credential chain over hardcoded keys. In production on ECS/EKS/EC2/Lambda, omit credentials entirely. For CI and local dev, use AWS_PROFILE or a ~/.aws/credentials file. Only fall back to explicit env-var keys on a dev machine, and never commit them. Use aws/aws-sdk-php-laravel's AWS facade if you want AWS::make('s3')-style access, but it adds little beyond a container binding — the plain service provider above is usually simpler to reason about.
Push slow AWS work onto the queue. Anything that isn't user-blocking (thumbnail generation, SES sends, DynamoDB writes, S3 archive sweep) belongs in a job. The SDK's async API is also genuinely useful inside a job:
$promises = collect($keys)->map(fn ($k) => $s3->deleteObjectAsync([
'Bucket' => $bucket, 'Key' => $k,
]));
$results = Promise\Utils::settle($promises)->wait(); // all in parallel over curl_multi
For request-scoped fan-out, AsyncAws-style batching with Promise\Utils::all() cuts latency dramatically versus a foreach of blocking calls.
Use paginators and waiters instead of hand-rolled loops. listObjectsV2 truncated results are the single most common SDK bug in Laravel codebases:
$paginator = $s3->getPaginator('ListObjectsV2', ['Bucket' => $bucket, 'Prefix' => 'exports/']);
foreach ($paginator as $page) {
foreach ($page['Contents'] ?? [] as $object) { /* ... */ }
}
// Or non-lazily: $paginator->search('Contents[].Key')->toArray();
Waiters ($ec2->waitUntil('InstanceRunning', [...])) save you from writing poll loops for async resources, and they respect Waiter config for delay/attempts.
Presigned URLs for direct browser/mobile uploads — the pattern that keeps big files off your PHP workers:
$cmd = $s3->getCommand('PutObject', [
'Bucket' => $bucket, 'Key' => $key, 'ContentType' => 'image/jpeg',
]);
$url = $s3->createPresignedRequest($cmd, '+10 minutes')->getUri();
Return the URL plus required headers to the frontend; validate the resulting object's size/content-type server-side afterward (a presigned PUT does not enforce them unless you sign Content-Length via a POST policy).
S3 multipart for anything above ~100 MB. Use Aws\S3\MultipartUploader or the higher-level Aws\S3\Transfer:
(new MultipartUploader($s3, $path, [
'bucket' => $bucket, 'key' => $key,
'part_size' => 25 * 1024 * 1024,
'concurrency' => 5,
]))->upload();
Transfer also does recursive directory sync and is pause/resume-capable — good for nightly backup jobs driven by the scheduler.
Structured error handling. Catch AwsException, not \Exception:
try {
$s3->headObject(['Bucket' => $b, 'Key' => $k]);
} catch (S3Exception $e) {
if ($e->getAwsErrorCode() === 'NoSuchKey' || $e->getStatusCode() === 404) {
return null;
}
report($e); // Sentry/CloudWatch, includes request ID for AWS support
throw $e;
}
Log $e->getAwsRequestId() and $e->getAwsErrorType() — AWS support tickets and CloudTrail investigation both need the request ID. AwsException::isConnectionError() distinguishes network failures from service-shaped errors, which is the right pivot for retry decisions in queued jobs.
Local development and tests. Point at LocalStack or MinIO via endpoint + use_path_style_endpoint => true, only when a config flag is set. For unit tests, swap in Aws\MockHandler rather than mocking the client class:
$mock = new MockHandler([new Aws\Result(['Body' => 'ok'])]);
$s3 = new S3Client(['version' => 'latest', 'region' => 'us-east-1',
'credentials' => false, 'handler' => HandlerList::resolve($mock)]);
Trim the vendor tree. With 400+ service namespaces, vendor/aws is heavy on cold-start-heavy deploys. Enable the built-in composer script in composer.json extra to strip services you never instantiate (documented under src/Script/Composer) — commonly a 60–80% size cut.
Let the service models carry new features for you. Most releases are additive model updates: new parameters and enums appear in the generated client, so $polly->synthesizeSpeech([...]) gained the new generative voices and $mediaConvert->createJob([...]) gained AV1 CBR/scene-change settings with no code change beyond passing the new keys. Read the release notes for the services you actually call (S3, SES v2, SQS, Polly, MediaConvert) and ignore the rest; you don't need to re-architect anything.
version => 'latest' triggers an API manifest lookup and caches it; it's fine, but pin a concrete version string ('2006-03-01') in production if you want deterministic behavior across deploys. It's not the SDK's own version.env() outside config files breaks under php artisan config:cache. Keep every AWS setting in config/services.php and read config() everywhere else.169.254.169.254. Set AWS_EC2_METADATA_DISABLED=true locally, or provide explicit credentials. A 2-second connect_timeout also catches this.http.connect_timeout and http.timeout globally; otherwise a single stuck S3 call can pin a PHP-FPM worker forever and take the site down under load.retries per client and make sure write operations are idempotent or you'll create duplicate S3 objects / DynamoDB items.ACL rejected because Object Ownership is set to bucket-owner-enforced. Read getAwsErrorCode() before assuming.ACL => 'public-read' is dead on most new buckets. Default to private + presigned URLs or CloudFront. Code copied from old tutorials will fail with AccessControlListNotSupported.$result['Body']->getContents() loads the whole object into PHP memory. Use $s3->getObject(['...','SaveAs' => $localPath]), the s3:// stream wrapper, or StreamWrapper::register() + fopen('s3://bucket/key', 'r') for streaming reads.$timeout. If your job has a 5-minute timeout and the queue visibility is 30 seconds, the message reappears and runs twice. Either use Sqs::changeMessageVisibility with heartbeat middleware or set the queue's visibility comfortably above the job timeout.'debug' => true in the client config (or a Guzzle Middleware::log) for a full request/response dump — but never leave it on in production; it writes credentials-adjacent headers and full payloads to logs.Aws\Signature\SignatureV4 explicitly rather than replicating the algorithm.us-east-1 reading a bucket in eu-west-1 returns a 301 PermanentRedirect with a x-amz-bucket-region header — the SDK surfaces it as an error but the message is easy to misread. For cross-region buckets use S3Client::getBucketLocation or just build per-region clients.HandlerList (add correlation IDs, metrics, cost tagging), custom CredentialProvider, and custom EndpointProvider are the three clean seams if you need cross-cutting behavior. A middleware that pushes latency into CloudWatch via EMF is a 30-line win for production observability.FinSpace and FinSpaceData clients outright because the services were retired — no major-version bump, so new FinSpaceClient(...) now fails with a class-not-found fatal, outside any catch (AwsException) you wrote. Before bumping, grep -r "FinSpace\|FinSpaceData" app/ config/ composer.json (and check your dependency tree) for retired clients; treat "does this class still exist?" as part of the upgrade checklist rather than assuming AWS only ever adds.composer update aws/aws-sdk-php --dry-run to see the drift, then read the changelog filtered to the services you instantiate. Pin the major version, and diff the generated client signatures for those services if a deploy starts throwing InvalidArgumentException on an operation call.How can I help you explore Laravel packages today?