objects namespaces and application architecture
Lazy Objects
A lazy object delays expensive work until the moment it is actually needed. In PHP applications, lazy loading appears in ORMs, service containers, proxies, generated clients, and objects that wrap expensive resources.
The benefit is avoiding work that may never be used. The cost is hidden timing: reading a property or calling a method may suddenly load data, open a connection, or throw an exception.
Eager Versus Lazy Work
Eager code does the work immediately.
<?php
declare(strict_types=1);
final class Report
{
public function __construct(
public readonly string $body,
) {
}
}
function loadReport(): Report
{
echo 'Loading report' . PHP_EOL;
return new Report('Revenue report');
}
$report = loadReport();
echo $report->body . PHP_EOL;
// Prints:
// Loading report
// Revenue report
Lazy code waits until the value is requested.
<?php
declare(strict_types=1);
final class LazyReport
{
private ?Report $report = null;
/** @param callable(): Report $loader */
public function __construct(
private $loader,
) {
}
public function body(): string
{
if ($this->report === null) {
$this->report = ($this->loader)();
}
return $this->report->body;
}
}
final class Report
{
public function __construct(
public readonly string $body,
) {
}
}
$report = new LazyReport(function (): Report {
echo 'Loading report' . PHP_EOL;
return new Report('Revenue report');
});
echo 'Created lazy object' . PHP_EOL;
echo $report->body() . PHP_EOL;
// Prints:
// Created lazy object
// Loading report
// Revenue report
The loader runs only when body() is called.
Lazy Proxies
A proxy object can implement the same interface as the real object and load the real object on first use.
<?php
declare(strict_types=1);
interface InvoiceRepository
{
public function countOverdue(): int;
}
final class DatabaseInvoiceRepository implements InvoiceRepository
{
public function __construct()
{
echo 'Opening database connection' . PHP_EOL;
}
public function countOverdue(): int
{
return 3;
}
}
final class LazyInvoiceRepository implements InvoiceRepository
{
private ?InvoiceRepository $repository = null;
/** @param callable(): InvoiceRepository $factory */
public function __construct(
private $factory,
) {
}
public function countOverdue(): int
{
return $this->repository()->countOverdue();
}
private function repository(): InvoiceRepository
{
if ($this->repository === null) {
$this->repository = ($this->factory)();
}
return $this->repository;
}
}
$repository = new LazyInvoiceRepository(
fn (): InvoiceRepository => new DatabaseInvoiceRepository(),
);
echo 'Repository injected' . PHP_EOL;
echo $repository->countOverdue() . PHP_EOL;
// Prints:
// Repository injected
// Opening database connection
// 3
The application can receive an InvoiceRepository without opening the database connection until a repository method is actually called.
Where You See Lazy Objects
Doctrine and other ORMs use lazy loading for relationships. A User object may contain a lazy collection of orders that is loaded when the collection is accessed.
Service containers may use lazy services so expensive dependencies are not built until needed.
Frameworks and libraries may use generated proxy classes or PHP's newer lazy-object support internally. As an application developer, you usually interact with the behaviour rather than hand-writing low-level lazy-object machinery.
Risks
Lazy loading can hide performance problems. A loop that looks harmless may trigger one query per item.
<?php
declare(strict_types=1);
foreach ([1, 2, 3] as $userId) {
echo 'Would load orders for user ' . $userId . PHP_EOL;
}
In real ORM code, that pattern can become an N+1 query problem: one query to load users, then one extra query for each user's related data.
Lazy objects can also make errors happen later than expected. Construction succeeds, but the first method call fails because a connection cannot be opened.
When Lazy Loading Helps
Lazy loading is useful when:
- construction is expensive
- many code paths do not need the object
- the object is behind a clear interface
- the delayed failure is acceptable and handled
- profiling shows the eager work is wasteful
Avoid laziness when it makes behaviour surprising. If a method must always use the dependency, building it eagerly may be clearer.
What You Should Be Able To Do
After this lesson, you should be able to explain that lazy objects delay work until first use, recognise lazy proxies in containers and ORMs, and identify risks such as hidden queries and late failures.
For junior work, the practical skill is noticing when simple property or method access may be doing more work than it appears to do.
Practice
Practice: Build A Lazy Repository Proxy
Create a small PHP example of a lazy proxy.
Task
Build:
- an
InvoiceRepositoryinterface - a concrete repository that prints a message when constructed
- a lazy proxy that implements the same interface
- a factory callback that creates the concrete repository only when needed
Use strict types. Keep the expected output in the PHP code block as printed lines or comments.
Check Your Work
Confirm:
- creating the lazy proxy does not create the concrete repository
- calling a repository method creates the concrete repository
- the second method call reuses the same concrete repository
Afterward, explain one risk of lazy loading in a real ORM or service container.
Show solution
This solution delays constructing the concrete repository until countOverdue() is called.
<?php
declare(strict_types=1);
interface InvoiceRepository
{
public function countOverdue(): int;
}
final class DatabaseInvoiceRepository implements InvoiceRepository
{
public function __construct()
{
echo 'Opening database connection' . PHP_EOL;
}
public function countOverdue(): int
{
return 3;
}
}
final class LazyInvoiceRepository implements InvoiceRepository
{
private ?InvoiceRepository $repository = null;
/** @param callable(): InvoiceRepository $factory */
public function __construct(
private $factory,
) {
}
public function countOverdue(): int
{
return $this->repository()->countOverdue();
}
private function repository(): InvoiceRepository
{
if ($this->repository === null) {
$this->repository = ($this->factory)();
}
return $this->repository;
}
}
$repository = new LazyInvoiceRepository(
fn (): InvoiceRepository => new DatabaseInvoiceRepository(),
);
echo 'Lazy proxy created' . PHP_EOL;
echo $repository->countOverdue() . PHP_EOL;
echo $repository->countOverdue() . PHP_EOL;
// Prints:
// Lazy proxy created
// Opening database connection
// 3
// 3
One risk is hidden performance cost. In an ORM, accessing a property inside a loop may silently trigger many database queries.