Intermediate · 50 MINUTES · VERSION 1
PHP interfaces and explicit dependency injection
Wire a writer to a small interface and test validation, delegation and failure without pretending a memory fake is durable storage.
Reviewed 23 Oct 2026Your setup may differ. Windows, Ubuntu, and optional tool bundles →
What you will learn
- Define an interface with one explicit responsibility.
- Inject an implementation through a constructor without a framework container.
- Test validation and dependency failures without a real database.
Theory: name the boundary
An interface declares methods that an implementation must provide. It describes a type-level contract; it does not prove that storage really persisted a record. Dependency injection means a caller supplies the collaborator a class needs. Constructor injection makes that requirement visible when creating the object.
The writer below validates a short line and delegates storage to NoteSink. It does not construct a database client secretly. A CLI composition root chooses MemoryNoteSink for this exercise. In Laravel, the service container can perform object construction and interface bindings, but understanding ordinary PHP wiring comes first.
Use an interface where a real boundary needs alternative implementations or a controlled failure test. Adding an interface to every tiny class can create needless navigation and ceremony. Keep a useful responsibility small; do not claim an interface itself provides transactions, authorization, durability or retry safety.
Worked example: an explicit note sink
Required: PHP 8.4+ CLI. Save the whole file as note_dependencies.php. No database, HTTP service or paid API is used. Laragon/XAMPP are optional.
<?php
declare(strict_types=1);
interface NoteSink
{
public function store(string $line): void;
}
final class MemoryNoteSink implements NoteSink
{
private array $lines = [];
public function store(string $line): void
{
$this->lines[] = $line;
}
public function lines(): array
{
return $this->lines;
}
}
final class UnavailableNoteSink implements NoteSink
{
public function store(string $line): void
{
throw new RuntimeException('Storage unavailable.');
}
}
final class StudyNoteWriter
{
public function __construct(private NoteSink $sink) {}
public function record(string $line): void
{
$line = trim($line);
if ($line === '' || strlen($line) > 120) {
throw new InvalidArgumentException('Use a note from 1 to 120 bytes.');
}
$this->sink->store($line);
}
}
if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
$memory = new MemoryNoteSink;
$writer = new StudyNoteWriter($memory);
$writer->record(' I checked the empty case. ');
echo 'Notes this process: '.count($memory->lines()).PHP_EOL;
echo 'First note: '.$memory->lines()[0].PHP_EOL;
try {
(new StudyNoteWriter(new UnavailableNoteSink))->record('Report failure honestly.');
} catch (RuntimeException $error) {
echo 'Failure: '.$error->getMessage().PHP_EOL;
}
}
Run and explain the result
Windows PowerShell:
php --version
php ./note_dependencies.php
Ubuntu Bash:
php --version
php ./note_dependencies.php
Notes this process: 1
First note: I checked the empty case.
Failure: Storage unavailable.
PHP must be on PATH; use the general setup guide if not. Source is common across the two labelled shells. Local verification uses Windows PHP 8.4.26; Ubuntu execution remains unverified.
The constructor receives a NoteSink. Record trims and validates before calling it. MemoryNoteSink changes only its in-process list: the example does not save a file or database row. Its data disappears when the process ends. The unavailable implementation throws, and the caller reports failure. A void return type does not mean data survived a restart. The direct-execution guard keeps require-based tests quiet.
Guided lab
- Run the demo and identify the writer, interface, two implementations and composition root.
- Keep a reference to MemoryNoteSink, call record twice and inspect its two lines in order. Make a second sink and confirm it starts empty.
- Try blank text and 121 bytes. Verify the sink receives no new line. Try 120 bytes and the text 0; both are allowed by the stated rule.
- Inject UnavailableNoteSink. Confirm the error reaches the caller rather than producing a false saved message.
- Make a test spy that counts store calls. One accepted record should delegate once; one rejected record should delegate zero times.
Failure is not always proof of no write
This deliberately unavailable sink throws before doing anything. A real remote service could accept a write and then lose the response. A database/file implementation could fail during another step. Therefore do not add blind retry around an arbitrary store method. Define what success/failure means, how duplicate effects are prevented, and which operations are safe to retry.
A memory fake tests writer orchestration. It cannot prove SQL constraints, filesystem permissions, transactions, concurrent uniqueness or real mail/API delivery. Add separate integration tests when implementing a real adapter. Never let an exercise execute visitor-submitted source on this website.
Independent exercise
Write a PrefixNoteSink that implements NoteSink and receives another NoteSink. Prefix accepted lines with study: before delegating. Compose it with MemoryNoteSink and verify one correctly prefixed line reaches the inner sink. Do not extend MemoryNoteSink; use composition.
Test two calls, no cross-instance state, exact prefix, and propagation of an inner failure. State whether your adapter validates lengths before or after adding the prefix, and keep the writer’s original 120-byte input contract unchanged. Hint: the interface can be a constructor parameter for an adapter too.
Common mistakes and interview
Common mistakes: constructing concrete dependencies inside the writer; treating a fake pass as a real database pass; swallowing all errors and displaying saved; automatically retrying an operation with unknown effects; and creating an abstraction with no concrete purpose.
Interview: “How would you test a service that writes data?” Explain the boundary and a memory/spy/failing implementation, then the integration tests needed for the real adapter. Explain a write-succeeded/response-lost case and why idempotency matters.
English practice and glossary
- Interface: a declared method contract.
- Dependency: a collaborator used by another object.
- Injection: supplying that collaborator explicitly.
- Composition root: the place where implementations are selected and connected.
- Fake: a simpler implementation used for a controlled test.
- Adapter: an implementation that connects a boundary to another component.
Say: “The writer depends on __. In this test I replace __ with __. This proves __, but it does not prove __.” Save the explanation and actual test commands in your private journal.
Recap and next step
Keep validation and dependency selection visible. Test accepted, rejected and failing paths without confusing in-memory evidence with persistence. Next study namespaces, Composer autoloading and the Laravel container; later practise transactions and idempotent adapters.
Reveal exercise hints and self-check answers
The writer depends on NoteSink; the caller selects MemoryNoteSink or UnavailableNoteSink. Blank/oversized input is rejected before storage. Memory state belongs to a sink instance and is not durable. An exception from a real adapter does not universally prove that no write occurred.
Exercise outline: store an injected inner sink, implement store by calling the inner store with a documented prefix, and avoid catching an inner error unless you can preserve its meaning. Verify accepted delegation count and the difference between input length and stored length.