Intent

Provide a surrogate or placeholder for another object to control access to it. The proxy implements the same interface as the real subject, so callers can't tell the difference — but the proxy decides what, when, and whether the real call goes through.

The Problem

You've got a DocumentService that loads PDFs from S3. The class itself works fine, but in production a few patterns are causing pain:

  • The PDFs are large (5–50 MB). Loading them on every page render is slow and expensive — you only need the metadata for the listing page.
  • Some documents are confidential. Every controller method that touches them needs to check the caller's permissions first — and that check is duplicated in eight places.
  • The same document is requested 100 times an hour. Every request hits S3.

The naive options:

  1. Add if ($lazy) return new EmptyDocument() branches to DocumentService. Now the service knows about lazy loading. Add an if ($user->canRead(...)) for auth — now it knows about auth too. Add a static cache — now it knows about caching. The class swells; the concerns interleave; tests multiply.
  2. Move the concerns out to controllers. Now every controller checks auth, decides whether to load lazily, manages the cache. Eight copies of the same dance.

Both paths end with the same pain: cross-cutting concerns leaking into either the subject or its callers, with no clean boundary.

The Solution

Proxy says: write a wrapper class that implements the same interface as the real subject (Document), holds a reference to the real subject, and intercepts every method call. The proxy decides whether to forward the call to the real subject — and if it does, it might also do something extra around it (cache the result, check the caller, lazily construct the real subject).

PHP
interface Document
{
    public function content(): string;
    public function metadata(): array;
}

final class CachingDocumentProxy implements Document
{
    private ?string $cached = null;

    public function __construct(private Document $real) {}

    public function content(): string
    {
        return $this->cached ??= $this->real->content();
    }

    public function metadata(): array
    {
        return $this->real->metadata();
    }
}

Callers receive a Document and never know which kind. You can stack proxies — a LoggingProxy wrapping an AuthProxy wrapping a CachingProxy wrapping a RealDocument — and each handles one concern.

Real-World Analogy

A bank teller. When you ask for $1,000, they don't open the vault and let you grab it yourself. The teller is a proxy for the vault: they accept your request, check your account balance, verify your ID, confirm you're under your daily withdrawal limit — and then, if everything checks out, they go to the vault and bring back exactly what you asked for.

From your perspective, the interaction is identical to walking into the vault would have been ("I get money"). But the teller controls every step of access: authorization, rate limiting, audit logging. The vault never sees you directly.

Structure

Proxy pattern: a Document interface, a RealDocument that loads from S3, and a CachingDocumentProxy that implements Document and holds a RealDocument.
Proxy: same interface as the subject, controls access to it.

Three roles you'll see in every Proxy implementation:

  • Subject — the interface that both the real subject and the proxy implement. Here: Document.
  • Real Subject — the object that actually does the work. Here: S3Document.
  • Proxy — the wrapper. Implements the Subject interface. Holds a reference to the Real Subject (sometimes constructs it lazily). Intercepts every method, decides what to do, optionally calls through. Here: CachingDocumentProxy, AuthDocumentProxy, LazyDocumentProxy.

The defining property: the Proxy and the Real Subject share exactly the same interface. That's what lets the Proxy substitute for the real thing without callers noticing.

Code Examples

Here's a lazy-loading proxy for an S3 document in five languages. The proxy doesn't fetch the content until someone actually asks for it — so creating a proxy is cheap, even when the underlying document is huge.

interface Document {
  content(): Promise<string>;
  metadata(): Metadata;
}

class S3Document implements Document {
  constructor(private id: string) {}

  async content(): Promise<string> {
    return await s3.getObject(this.id);   // expensive; fetches the whole PDF.
  }

  metadata(): Metadata {
    return { id: this.id, kind: "pdf" };
  }
}

export class LazyDocumentProxy implements Document {
  private real: S3Document | null = null;

  constructor(private id: string) {}

  async content(): Promise<string> {
    this.real ??= new S3Document(this.id);
    return await this.real.content();
  }

  metadata(): Metadata {
    return { id: this.id, kind: "pdf" };   // cheap — no need to load.
  }
}
from abc import ABC, abstractmethod

class Document(ABC):
    @abstractmethod
    def content(self): ...
    @abstractmethod
    def metadata(self): ...

class S3Document(Document):
    def __init__(self, doc_id):
        self._id = doc_id
    def content(self):
        return s3.get_object(self._id)   # expensive
    def metadata(self):
        return {"id": self._id, "kind": "pdf"}

class LazyDocumentProxy(Document):
    def __init__(self, doc_id):
        self._id = doc_id
        self._real = None

    def content(self):
        if self._real is None:
            self._real = S3Document(self._id)
        return self._real.content()

    def metadata(self):
        return {"id": self._id, "kind": "pdf"}   # cheap
public interface Document {
    String content();
    Metadata metadata();
}

public final class S3Document implements Document {
    private final String id;
    public S3Document(String id) { this.id = id; }

    @Override public String content() {
        return S3.getObject(id);   // expensive
    }
    @Override public Metadata metadata() {
        return new Metadata(id, "pdf");
    }
}

public final class LazyDocumentProxy implements Document {
    private final String id;
    private S3Document real;

    public LazyDocumentProxy(String id) { this.id = id; }

    @Override public String content() {
        if (real == null) real = new S3Document(id);
        return real.content();
    }

    @Override public Metadata metadata() {
        return new Metadata(id, "pdf");   // cheap — no fetch
    }
}
<?php

namespace App\Documents;

interface Document
{
    public function content(): string;
    public function metadata(): array;
}

final class S3Document implements Document
{
    public function __construct(private string $id) {}

    public function content(): string
    {
        return S3::getObject($this->id);   // expensive
    }

    public function metadata(): array
    {
        return ['id' => $this->id, 'kind' => 'pdf'];
    }
}

final class LazyDocumentProxy implements Document
{
    private ?S3Document $real = null;

    public function __construct(private string $id) {}

    public function content(): string
    {
        $this->real ??= new S3Document($this->id);
        return $this->real->content();
    }

    public function metadata(): array
    {
        return ['id' => $this->id, 'kind' => 'pdf'];   // cheap
    }
}
package documents

type Document interface {
    Content() (string, error)
    Metadata() Metadata
}

type S3Document struct {
    ID string
}

func (d S3Document) Content() (string, error) {
    return S3.GetObject(d.ID)   // expensive
}
func (d S3Document) Metadata() Metadata {
    return Metadata{ID: d.ID, Kind: "pdf"}
}

type LazyDocumentProxy struct {
    id   string
    real *S3Document
}

func NewLazyDocumentProxy(id string) *LazyDocumentProxy {
    return &LazyDocumentProxy{id: id}
}

func (p *LazyDocumentProxy) Content() (string, error) {
    if p.real == nil {
        p.real = &S3Document{ID: p.id}
    }
    return p.real.Content()
}

func (p *LazyDocumentProxy) Metadata() Metadata {
    return Metadata{ID: p.id, Kind: "pdf"}   // cheap
}

In every language the trick is the same: the proxy implements the same interface, defers the expensive content() until someone actually asks for it, and answers cheap metadata() queries without ever instantiating the real subject. Listing pages stay fast; full-document views still get what they need.

When to Use It

Reach for Proxy when you can answer "yes" to any of these:

  • Lazy loading — the real subject is expensive to construct, and most callers never need it. Proxy defers construction until first real use.
  • Access control / authorization — every call should be checked before reaching the subject. Proxy adds the check uniformly without modifying the subject.
  • Caching / memoization — the real subject's results are stable for a window of time. Proxy caches them and only forwards on miss.
  • Logging / metrics / audit — every call needs to be recorded. Proxy intercepts uniformly.
  • Remote calls — the real subject lives in another process or machine. Proxy presents it as a local object and handles serialization, networking, and retries internally. (This is what RPC client stubs are.)
  • Smart references — counting how many places reference the real subject, freeing it when the count hits zero (less common in garbage-collected languages, classic in C++).

If you don't need any control on access — you just want to add behavior — what you want is a Decorator, not a Proxy. The shape is identical; the intent isn't.

Pros and Cons

Pros

  • Cross-cutting concerns (auth, caching, lazy loading, logging) move out of the subject and out of every caller.
  • Each concern is its own proxy class — small, single-purpose, individually testable.
  • Proxies stack cleanly: LoggingProxy(AuthProxy(CachingProxy(real))) — each layer adds one concern in the right order.
  • The subject doesn't know it's being proxied. You can swap in a proxy without touching subject code or caller code.
  • Works as a clean pattern for remote calls — the same code shape that lets you fake a call locally also lets you make it cross-network.

Cons

  • Adds indirection. Every call passes through the proxy on its way to the real subject. For very hot paths, the cost matters.
  • Stack traces get longer. Debugging "why didn't this method return?" now has an extra hop.
  • Easy to confuse with Decorator. Same shape, different intent — choose by what the wrapper is for, not by what the wrapper looks like.
  • Proliferation. A subject with five concerns has five proxy classes. If concerns are independent, that's actually a feature; if they're not, it becomes a tangle.

Pro Tips

  • Keep each Proxy single-purpose. "AuthCachingLoggingProxy" is a god class with a misleading name. Make three proxies and stack them.
  • Inject the real subject through the constructor. A proxy that constructs its real subject is hard to substitute in tests; one that receives it is easy.
  • Decide what to do on the cheap calls. A metadata() method that doesn't need the real subject should answer without instantiating it. That's where the lazy-loading proxy earns its keep.
  • Make the order of stacking explicit. auth → cache → log → real is not the same as cache → auth → log → real. Document the chain and don't let it drift.
  • Don't let the Proxy change what the call returns beyond what the contract permits. The whole point is interface-identity. If you start re-shaping responses, you're really an Adapter.

Relations with Other Patterns

  • Decorator has the same wrapping shape, but a different intent. Decorator adds behavior to an object; Proxy controls access to one. If your wrapper's job is "decide whether to let this through, or how, or cache it, or check who's asking," it's a Proxy. If it's "always let it through and add something around it," it's a Decorator.
  • Adapter also wraps an object, but it changes the interface; Proxy preserves it. Adapter is for shape mismatch; Proxy is for access control with the same shape.
  • Facade simplifies a subsystem of objects by presenting a new interface; Proxy substitutes for one object with the same interface.
  • Smart references (a kind of Proxy) and Flyweight sometimes overlap when you want both shared state and access control on the shared object.

Final Tips

The Proxy that earned its keep on a project I worked on was a LazyAttachmentProxy for email attachments. The metadata view showed lists of 50 attachments at a time; instantiating each one fully would have meant 50 S3 requests for content that would never get viewed. The proxy made metadata() a cheap no-fetch lookup; download() triggered the actual S3 read on demand. The page that previously took two seconds dropped to 80 milliseconds — and the only thing that changed was one constructor call in the factory that produced the attachments.

That's the deep promise: same interface, different cost. Reach for Proxy when what changes isn't the API surface, but what happens behind it — and the change is one of the classics: lazy, cached, audited, or controlled.