Object Storage: S3-Compatible Stores, ACLs, Signed URLs, and Lifecycle Policies
Explain what object storage is and how it differs from local filesystem or block storage, correctly choose between private and public files, generate signed URLs for temporary access to private objects, and configure lifecycle policies to manage storage cost over time.
The previous lesson established that a stateless, horizontally-scaled PHP app can't keep uploaded files on local disk. Object storage is the standard answer: a purpose-built, network-accessible service for storing and retrieving files (called 'objects') at effectively unlimited scale, reachable identically from every application server. This lesson goes deeper into what object storage actually is, how private and public access work, how to grant temporary access to a private file without making it fully public, and how to control what happens to old objects automatically over time.
What Object Storage Is, and How It Differs from a Filesystem
A local or 'block' filesystem — the kind your operating system gives you, and the kind PHP's fopen()/file_put_contents() functions talk to by default — organizes data as a hierarchy of directories and files, addressed by a path, and is tightly coupled to one physical machine (or one attached disk volume). Object storage takes a radically simpler, flatter model: every stored item is an 'object' — essentially a blob of bytes plus some metadata — addressed by a unique key (a string that often looks like a path, e.g. avatars/user-42.jpg, but is really just an opaque identifier) inside a 'bucket' (a top-level namespace, roughly analogous to a top-level folder, but with its own permissions and configuration).
Object storage is accessed over HTTP(S) using a REST API, not through filesystem system calls — you PUT an object to store it and GET it to retrieve it, from anywhere with network access and the right credentials, rather than needing to be logged into the specific machine where a disk is physically attached. This is precisely what makes it a natural fit for a multi-server PHP deployment: every application server, regardless of which one it is, talks to the exact same bucket over the network.
The dominant API for object storage is Amazon S3's, which has become a de facto industry standard: AWS S3 itself is the original and most widely used implementation, but the same API is also implemented by self-hosted or alternative-cloud options like MinIO (open-source, self-hostable, commonly run in Docker for local development or on-prem deployments) and DigitalOcean Spaces (a managed, S3-API-compatible service). Because they share the same API shape, code written against the AWS SDK for PHP can often be pointed at MinIO or Spaces just by changing the endpoint URL and credentials — a valuable portability property when you don't want to be locked into one cloud vendor.
• Object storage = flat collection of objects (blob + metadata) addressed by key, inside a bucket, accessed over HTTP via a REST API. • No physical machine coupling — any server with credentials and network access reaches the exact same objects. • Amazon S3's API is the de facto standard; MinIO and DigitalOcean Spaces implement the same API, giving portability across providers. • PUT to store, GET to retrieve — the mental model is much flatter than a real filesystem's directory tree.
Private vs Public Objects and Bucket/Object ACLs
Every bucket, and every object within it, has an access policy controlling who can read or write it. A public object is reachable by anyone who has (or guesses) its URL, with no authentication required — appropriate for things like a site's logo, publicly-shared blog post images, or any asset that's meant to be openly linkable. A private object requires the requester to present valid credentials (or, as covered next, a signed URL) before the storage service will serve it — appropriate for essentially everything else: user avatars a user hasn't chosen to make public, invoices, private documents, paid downloadable content, and any file that could leak personal information if guessed or crawled.
The safe default is private-by-default: configure new buckets and objects to deny public access unless a specific object is deliberately and knowingly made public. Cloud providers increasingly enforce this at the account/bucket level — AWS, for instance, has 'S3 Block Public Access' settings that must be explicitly disabled before any object in a bucket can be made public at all, specifically because accidentally-public S3 buckets containing sensitive data have been one of the most common and damaging classes of real-world data breaches.
Access control is expressed either as an ACL (Access Control List — a simpler, older mechanism attached directly to a bucket or object, e.g. 'private', 'public-read') or, in modern AWS usage, a bucket policy (a JSON document expressing more flexible rules, such as 'allow public read only on objects under the /public/ prefix'). For most application code, the practical rule of thumb is: never set an object to public unless there's a specific, considered reason it needs to be openly link-able, and prefer generating signed URLs over flipping objects to public just to make temporary access easier.
• Public object: readable by anyone with the URL, no auth needed — for openly shareable assets only. • Private object: requires credentials or a signed URL — the correct default for almost everything. • Configure buckets private-by-default; make individual objects public only deliberately, never by accident. • Access is expressed via ACLs (simple, per-object/bucket) or bucket policies (flexible, JSON-based rules).
Signed URLs: Temporary Access to Private Objects
A signed URL (also called a presigned URL) is a normal-looking URL to a private object that has a cryptographic signature and an expiration time embedded in its query string. The storage service verifies that signature on each request: if it's valid and the URL hasn't expired, the object is served even though the requester never presented separate credentials — the proof of authorization is baked directly into the URL itself. This is the standard way to let a specific, verified user temporarily access a private file without making that file public to the entire internet.
The classic use cases are exactly the kinds of files that must stay private but still need to reach a legitimate browser: a user's private avatar that only their own logged-in session (or people they've explicitly shared it with) should see, a paid e-book or software download that should only be fetchable for a limited window after purchase, or a generated PDF invoice that a specific customer should be able to download from an email link without needing to log in first. In each case, your PHP application checks that the requester is actually authorized (are they logged in as this user? did they actually pay for this download?) and only then asks the storage SDK to generate a signed URL, typically valid for anywhere from a few minutes to a few hours depending on the use case.
The security value of a signed URL comes specifically from its short expiration — a link valid for 15 minutes that leaks into a browser history or gets forwarded is a much smaller exposure than a permanently public URL, and once it expires, generating a fresh one requires going back through your application's own authorization check again. It's important to still choose an appropriately short expiration for the sensitivity of the data — a signed URL valid for 30 days for a highly sensitive document defeats much of the purpose.
• Signed URL: a normal URL with an embedded signature + expiry that grants temporary access to a private object. • Generated server-side by your app, only after your app's own authorization check passes. • Use cases: private avatars, paid downloads, invoice PDFs — anything private that still needs to reach a browser temporarily. • Security comes from short expiration — choose a TTL proportional to how sensitive the object is.
Lifecycle Policies: Automating What Happens to Old Objects
Object storage costs scale with how much data you keep and how 'hot' (immediately accessible) it needs to be — a lifecycle policy is a rule, configured on the bucket itself, that automatically transitions or deletes objects based on age, without any application code having to run a cleanup job. This matters enormously at scale: an application that stores millions of user-uploaded files, logs, or generated reports will otherwise accumulate storage costs indefinitely, most of it for data nobody ever accesses again after the first few days or weeks.
A typical lifecycle policy might say: after 30 days, transition objects in the logs/ prefix from the default 'hot' storage tier (fast, more expensive, ideal for frequently-accessed data) to a cheaper 'infrequent access' or 'cold' tier (slower to retrieve, sometimes with a retrieval delay of hours, but dramatically cheaper per gigabyte per month) — and after 365 days, delete them outright. Storage providers commonly offer several such tiers (AWS S3 has Standard, Standard-IA, Glacier, and Glacier Deep Archive, each progressively cheaper and slower), and lifecycle policies are how you move data through them automatically as it ages.
Lifecycle policies are configured once on the bucket (through the provider's console, CLI, or infrastructure-as-code) rather than in application logic, which is exactly the point — they run reliably regardless of whether your application code has a bug, is deployed, or is even running at all. Getting this right early avoids a common and expensive mistake: teams that never configure lifecycle rules often only notice their storage bill has quietly grown 10x after a year or two of unmanaged accumulation.
• Lifecycle policy = bucket-level rule that transitions or deletes objects automatically based on age. • Storage tiers trade retrieval speed for cost: hot (fast, expensive) → infrequent-access → cold/archive (slow, cheap). • Runs independent of application code — reliable even if the app has bugs or isn't deployed. • Prevents unmanaged storage cost growth from old, rarely-accessed objects (logs, stale uploads, expired exports).
Putting It Together in a PHP Application
In practice, a PHP application almost never talks to the raw S3 REST API directly — it uses an SDK, most commonly the official AWS SDK for PHP (aws/aws-sdk-php via Composer), which wraps authentication, request signing, retries, and error handling behind a clean object-oriented client. The same SDK, pointed at a different endpoint configuration value, works identically against MinIO or DigitalOcean Spaces, which is why development environments frequently run a local MinIO container so uploads can be tested without touching a real cloud bucket or incurring cost.
A typical upload flow is: the browser uploads a file to your PHP application as normal (via a standard HTML form and $_FILES), your application validates it (file type, size, virus scanning if warranted), then your application uploads the validated file to the object storage bucket using the SDK, and finally stores only the resulting object key (not the file itself) in your database, associated with the relevant record (a user, a post, an order). Whenever the file needs to be served, your application either serves the public URL directly (for public files) or generates a signed URL on demand (for private files) rather than ever proxying the file's bytes through PHP itself, which would defeat much of the performance benefit of using object storage in the first place.
This separation — files live in object storage, only references (keys/URLs) live in the database — is what keeps a PHP application's database small and fast, keeps every application server stateless with respect to files, and lets file storage scale independently of both the database and the application servers.
• SDKs (like aws/aws-sdk-php) wrap the underlying REST API's auth, signing, and retries. • The same SDK code targets AWS S3, MinIO, or Spaces by changing only the endpoint/credentials configuration. • Database stores only the object key/URL, never the file bytes themselves. • Serve public files via their direct URL; serve private files via a freshly generated signed URL — avoid proxying bytes through PHP.
Installing and Configuring the AWS SDK for PHP
The SDK client is configured with a 'region', credentials, and (for MinIO/Spaces) a custom 'endpoint' pointing away from real AWS. Never commit real access keys to source control — load them from environment variables or a secrets manager.
composer require aws/aws-sdk-php
# For local development against MinIO instead of real AWS:
# docker run -p 9000:9000 -e MINIO_ROOT_USER=admin \
# -e MINIO_ROOT_PASSWORD=supersecret minio/minio server /dataCode Examples
The application validates and uploads the file directly to object storage, marks it private explicitly, and persists only the resulting key — the file's bytes never touch the database and are no longer tied to whichever server handled the upload.
$s3->putObject([...'ACL' => 'private'...]);
createPresignedRequest embeds a signature and expiry directly in the URL's query string; the storage service checks both on each request, so the private object becomes temporarily fetchable without ever being made public. Expected result: A time-limited URL that returns the private object until it expires, then returns an access-denied error.
Common mistake: Generating a signed URL before checking that the current user is actually authorized to see that avatar defeats the entire point — the signature only protects the object from the storage service's side, not from your own application's authorization logic.
<?php
declare(strict_types=1);
require 'vendor/autoload.php';
use Aws\S3\S3Client;
$s3 = new S3Client([
'version' => 'latest',
'region' => 'us-east-1',
// Omit 'endpoint' for real AWS S3; set it to target MinIO/Spaces instead:
'endpoint' => getenv('S3_ENDPOINT') ?: null,
'credentials' => [
'key' => getenv('S3_ACCESS_KEY'),
'secret' => getenv('S3_SECRET_KEY'),
],
]);
$key = 'avatars/user-42.jpg';
$s3->putObject([
'Bucket' => 'sampleapp-uploads',
'Key' => $key,
'SourceFile' => $_FILES['avatar']['tmp_name'],
'ACL' => 'private', // explicit: not publicly readable
]);
// Only the key is stored in the database, not the file itself.
$pdo->prepare('UPDATE users SET avatar_key = ? WHERE id = ?')
->execute([$key, 42]);<?php
declare(strict_types=1);
// Assume $s3 is the same configured S3Client as above, and
// the caller has already been authenticated/authorized above this point.
function getAvatarUrl(S3Client $s3, string $key): string {
$command = $s3->getCommand('GetObject', [
'Bucket' => 'sampleapp-uploads',
'Key' => $key,
]);
// Valid for 15 minutes from the moment it's generated.
$request = $s3->createPresignedRequest($command, '+15 minutes');
return (string) $request->getUri();
}
echo getAvatarUrl($s3, 'avatars/user-42.jpg');
// https://sampleapp-uploads.s3.amazonaws.com/avatars/user-42.jpg
// ?X-Amz-Algorithm=...&X-Amz-Expires=900&X-Amz-Signature=...Public vs Private Object Access
A signed URL is the only way to fetch a private object without full storage-service credentials — and only until it expires.
Public object:
Browser ────GET (no auth)───▶ Object Storage ──▶ 200 OK, file served
Private object without a signed URL:
Browser ────GET (no auth)───▶ Object Storage ──▶ 403 Forbidden
Private object WITH a signed URL:
Browser ─GET ?Signature=...─▶ Object Storage ──▶ 200 OK (until expiry)
│
signature + expiry checked hereImportant Rules
Default every new bucket and object to private; make something public only for a specific, considered reason.
Always run your own application-level authorization check before generating a signed URL.
Choose signed-URL expiration proportional to the sensitivity of the data — minutes for highly sensitive files, longer only when appropriate.
Store only object keys/URLs in the database — never store file bytes in a database column meant for object storage.
Configure lifecycle policies on any bucket that accumulates data over time (logs, exports, stale uploads) rather than relying on manual cleanup.
Signed URLs for Paid Downloads
An online course platform storing video lessons in a private S3 bucket generates a short-lived signed URL only after verifying a student has an active enrollment for that course, so the underlying video file is never publicly linkable, can't be freely redistributed via a permanent URL, and access can be revoked simply by no longer issuing new signed URLs to that student.
$s3->putObject([..., 'ACL' => 'public-read']); // for every uploaded avatar, 'to keep things simple'
$s3->putObject([..., 'ACL' => 'private']); // then serve via a signed URL after an authorization check
Marking every uploaded file public regardless of sensitivity means anyone who guesses or scrapes an object key can access it forever, with no expiration and no authorization check — the exact anti-pattern behind many real-world data leaks.
// Never configuring a lifecycle policy on a bucket that stores years of daily log exports
// Lifecycle rule: logs/ → Infrequent Access after 30 days → delete after 365 days
Without a lifecycle policy, storage costs for rarely-accessed old data grow indefinitely and silently, often going unnoticed until a much larger bill arrives.
How does object storage fundamentally differ from a local filesystem? A. It's just a faster local disk B. It stores flat, key-addressed objects inside buckets, accessed over HTTP from any server, rather than files in a directory tree tied to one machine C. It can only store images, never other file types D. It requires PHP to be recompiled with a special flag
B. It stores flat, key-addressed objects inside buckets, accessed over HTTP from any server, rather than files in a directory tree tied to one machine — Object storage uses a flat bucket/key model accessed via a REST API over the network, decoupling stored data from any single physical machine — unlike a local filesystem.
What is a signed (presigned) URL used for? A. Making an object permanently public B. Encrypting an object at rest C. Granting temporary, time-limited access to a private object without making it publicly accessible D. Verifying the object's file type before upload
C. Granting temporary, time-limited access to a private object without making it publicly accessible — A signed URL embeds a cryptographic signature and expiration in its query string, letting the storage service grant temporary access to a specific private object without requiring separate credentials or making the object public.
What should the default access level be for a newly created bucket or object? A. Public, since most files eventually need to be shared B. Private, making objects public only deliberately and for a specific reason C. It doesn't matter as long as signed URLs are used sometimes D. Whatever the default happens to be, since providers choose sensible defaults
B. Private, making objects public only deliberately and for a specific reason — Private-by-default is the safe posture; accidentally-public buckets containing sensitive data are one of the most common and damaging real-world security incidents.
What does a lifecycle policy do? A. Automatically transitions or deletes objects based on age, configured at the bucket level independent of application code B. Encrypts objects automatically after a set time C. Prevents an object from ever being downloaded more than once D. Automatically renames objects to avoid key collisions
A. Automatically transitions or deletes objects based on age, configured at the bucket level independent of application code — Lifecycle policies are bucket-level rules that move objects to cheaper storage tiers or delete them after a configured age, running reliably regardless of the application's own code or uptime.
Design the storage approach for a feature where users can upload private PDF invoices that only they can download, for up to one year, after which invoices older than a year should move to a cheaper storage tier automatically. Describe the ACL setting, how downloads are served, and the lifecycle policy needed.
Upload each invoice PDF with ACL set to private and store only its object key in the database against the owning user's record. To serve a download, first verify the requesting user owns that invoice, then generate a short-lived signed URL (e.g. valid for a few minutes) and redirect the browser to it. Configure a bucket lifecycle policy that transitions objects under the invoices/ prefix to a cheaper infrequent-access tier after some period (e.g. 90 days) — since invoices must remain retrievable for a year, do not delete them, only transition their storage tier to reduce cost while keeping them downloadable.
Think about what happens at upload time, at download time, and what a bucket-level rule (not application code) should automatically do as time passes.