Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Object Storage Explained: S3 Buckets, Keys and Signed URLs Without the Confusion

About Post

Your app stores uploads in storage/app. Then you add a second server behind a load balancer, and half your users start getting 404s on files they uploaded five minutes ago. The file exists. It's just on the other server.

That's usually the day a team moves to object storage, and Amazon S3 is the name everyone knows. It's also the day a new set of confusions begins, because S3 looks like a hard drive in the cloud. It isn't one, and almost every S3 mistake comes from treating it like one.

The mental model: a giant coat check

A file system is a filing cabinet. There are drawers inside drawers (folders), you can open a file and change a page, and you can move a folder by dragging it.

Object storage is a coat check. You hand over a coat (the object), you get a ticket (the key), and when you show the ticket, you get the whole coat back. You can't swap a button while it's hanging there. You can only hand in a new coat under the same ticket.

In S3 terms:

  • A bucket is the coat check itself: a container with a globally unique name, living in one AWS region.
  • A key is the ticket: a string like contracts/2026/03/8f2c9a.pdf.
  • An object is the data plus its metadata (content type, size, custom headers).

Surprise 1: folders don't exist

That key above has slashes in it, and the AWS console shows it inside folders. But S3's namespace is flat. The slashes are just characters in the key, and "folders" are a display trick based on shared prefixes.

This has practical consequences:

  • Renaming a "folder" isn't one operation. It means copying every object to a new key and deleting the old one. Choose your key structure carefully up front.
  • Listing is by prefix. "Give me everything under contracts/2026/" is cheap and natural. "Find every PDF anywhere" means listing everything and filtering yourself.
  • Empty folders can't really exist. When a tool creates one, it's actually a zero-byte object with a key ending in /.

Surprise 2: you can't edit an object

There's no "open the file and append a line". Every write replaces the whole object. That's why S3 is wonderful for uploads, PDFs, images, backups and exports, and the wrong place for a log file you append to or a SQLite database.

The good news: since late 2020, S3 gives strong read-after-write consistency. Once a write succeeds, the next read returns the new version. Older blog posts that warn about "eventual consistency" are describing a problem that no longer exists.

File systemObject storage (S3)
StructureReal nested foldersFlat keys; folders are prefixes
EditingChange part of a fileReplace the whole object
AccessThrough the OS, one machineOver HTTP, from anywhere
ScalingLimited by the diskEffectively unlimited
Best forCode, temp files, local cacheUploads, documents, media, backups

Surprise 3: private is the default, and that's good

New S3 buckets block public access by default. Developers sometimes see this as an obstacle ("my image URL returns Access Denied!") and switch it off. Please don't, at least not for anything a user uploaded.

Think about what lives in a typical business app's storage: signed contracts, ID copies, invoices, payslips. If those sit in a public bucket, the only thing protecting them is that nobody has guessed the URL yet. URLs get pasted into emails, logged by proxies and kept in browser history. That's not security.

So keep the bucket private. Which raises the obvious question: how does a user download their own file?

Signed URLs: the valet key for one file

A presigned URL is a normal S3 URL with a signature in the query string. Your server creates it using its own AWS credentials, and it grants one specific permission (read this object, or upload to this key) until a set expiry time.

The browser fetches the file directly from S3. Your PHP process never touches the bytes, which matters a lot when the file is a large PDF or a video.

A few facts worth knowing:

  • Signing happens locally. Generating a URL doesn't call AWS; it's a cryptographic calculation, so it's fast.
  • The URL only carries the permissions of the credentials that signed it. If your IAM role can't read the object, neither can the URL.
  • If you sign with temporary credentials (for example, an EC2 instance role), the URL stops working when those credentials expire, even if you asked for a longer lifetime.
  • Anyone holding the URL can use it until it expires. Keep the lifetime short: minutes, not days.

The AWS docs on presigned URLs go into the details.

Doing it in Laravel

Laravel's filesystem wraps all of this nicely. Install the S3 adapter (composer require league/flysystem-aws-s3-v3 "^3.0"), set the AWS_* values in .env, and use the s3 disk.

// Store an upload under a key you control
$key = $request->file('document')->store("contracts/{$contract->id}", 's3');
$contract->update(['document_key' => $key]);

// Later: authorise first, then hand out a short-lived link
public function download(Contract $contract)
{
    $this->authorize('view', $contract);

    return redirect(Storage::disk('s3')->temporaryUrl(
        $contract->document_key,
        now()->addMinutes(5),
        ['ResponseContentDisposition' => 'attachment; filename="contract.pdf"'],
    ));
}

Notice what's happening: your app still decides who may download (the policy check), and S3 does the heavy lifting of serving it. The ResponseContentDisposition option makes the browser download the file with a friendly name instead of showing a random key.

For large uploads, the same idea works in reverse. Storage::disk('s3')->temporaryUploadUrl($key, now()->addMinutes(5)) returns a URL and headers that let the browser or mobile app PUT the file straight to S3. Your server only records the key once the upload is done. The filesystem docs cover both methods.

Store the key, not the URL. Save contracts/42/abc.pdf in your database, and generate a fresh signed URL each time someone needs it. Stored URLs expire, break when you change buckets or CDNs, and leak access if they ever get copied.

The mistakes I see most

  • Using the user's file name as the key. Names collide, contain odd characters and sometimes reveal information. Let Laravel generate a random name and keep the original name in a database column if you need it.
  • Making the bucket public "just for images". Use a separate public bucket (or a CDN in front) for truly public assets, and keep user documents in a private one.
  • Proxying downloads through PHP. Streaming a file from S3 to your server and then to the user doubles the work and ties up a PHP worker. Redirect to a signed URL instead.
  • Week-long signed URLs. If a link needs to work next week, generate it next week.
  • Forgetting it's not only AWS. Cloudflare R2, DigitalOcean Spaces and MinIO speak the same S3 API, so everything here applies, and MinIO is handy for local development.

In short

Object storage is a coat check, not a filing cabinet. Keys are tickets, folders are an illusion, objects are replaced rather than edited, and private files stay private while signed URLs hand out temporary, single-file access.

Get those four ideas right and S3 becomes one of the most boring, reliable parts of your stack, which is exactly what storage should be.

How do you structure your S3 keys: by user, by date, by record ID, or something else? I'm curious what has held up best for you over time.

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close