Lesson 10 / 12
Common Interface Standards
Common interfaces such as PSR-3; the consumer bound to the interface while the implementation changes in the setup file; an interface checking the signature but not the meaning; an implementation that leaves placeholders unfilled silently changing the output.
Published Updated
In this lesson
- Measures that a consumer bound to a common interface is not edited when the implementation changes
- Shows that the change is absorbed at the seam in the setup file
- Shows with an implicit measurement that an interface checks the signature but not the meaning
Contents
The Autoloading lesson gathered in one place where a class comes from. This lesson turns to the inside of the class: if the consumer is bound to a common interface and the implementation changes, where does this change reach? The same question, the same driver, the same reach measure.
A common interface standard is many libraries and applications agreeing on the same signature. PSR-3, the PHP community’s recommendation for logging, is the well-known example: you call a logger with a message and a context array, and the placeholders inside the message are replaced with the context values. This lesson does not use the whole of PSR-3 but a small form of the same idea: a Logger interface with a single method, and placeholders such as .
What a Standards Recommendation Means
PSR is the abbreviation of the standards recommendations the PHP community publishes, and it is cited by number: PSR-3 defines logging, PSR-4 defines autoloading. The word “recommendation” in the name is literal: the language does not check whether a recommendation is followed. A library adopting PSR-3 is that library’s own decision. Its value arises through use: if two libraries adopt the same interface, they can work in the same application without knowing about each other.
Who Knows What
The real job of a common interface is to bring together two pieces of code that do not know each other. In the driver below, a library takes a payment and wants to record it; an application decides where the record goes. The library knows only the Logger interface. The application knows the interface and writes two implementations. The two meet only at the interface: the library file contains the name of no implementation.
<?php // whoknows.php — the library knows the interface, the application picks the implementation mkdir('measure/whoknows', 0777, true); file_put_contents('measure/whoknows/library.php', <<<'PHP' <?php interface Logger { public function log(string $message): void; } class PaymentService { public function __construct(private Logger $logger) {} public function receive(int $amount): void { $this->logger->log("payment received: $amount"); } } PHP); file_put_contents('measure/whoknows/app.php', <<<'PHP' <?php class ScreenLogger implements Logger { public function log(string $message): void { echo $message, "\n"; } } class UppercaseLogger implements Logger { public function log(string $message): void { echo strtoupper($message), "\n"; } } PHP); $library = file_get_contents('measure/whoknows/library.php'); $mentions = str_contains($library, 'Screen') || str_contains($library, 'Uppercase'); echo 'library mentions an implementation: ', $mentions ? 'yes' : 'no', "\n"; require 'measure/whoknows/library.php'; require 'measure/whoknows/app.php'; (new PaymentService(new ScreenLogger()))->receive(50); (new PaymentService(new UppercaseLogger()))->receive(50);
library mentions an implementation: no payment received: 50 PAYMENT RECEIVED: 50
The first line shows that the library is unaware of the implementations. In the next two lines the same PaymentService worked with two different loggers, and its output changed accordingly. The library did not change by a single line. This is the reason for what the previous section measured: the seam is not inside the library but in a place such as setup.php, which selects the implementation. The interface draws the boundary, and the code on the two sides of the boundary stays closed to each other’s changes.
When No Logger Is Given
If the library is bound to the interface, the case where the application gives no implementation must also be considered. The common solution is to use an implementation that does nothing as the default; PSR-3 also recommends a separate empty logger class for this. This way the library does not have to check for null on every call, and an application that wants no logging binds nothing.
<?php // empty.php — without a logger the library works with the quiet implementation interface Logger { public function log(string $message): void; } class QuietLogger implements Logger { public function log(string $message): void {} } class ScreenLogger implements Logger { public function log(string $message): void { echo $message, "\n"; } } class ReportService { private Logger $logger; public function __construct(?Logger $logger = null) { $this->logger = $logger ?? new QuietLogger(); } public function generate(): int { $this->logger->log('report generated'); return 3; } } $quiet = (new ReportService())->generate(); echo "without logger: $quiet\n"; $onScreen = (new ReportService(new ScreenLogger()))->generate(); echo "on screen: $onScreen\n";
without logger: 3 report generated on screen: 3
Both calls returned the same value; only the second wrote a log line. Logging does not interfere with the real job of the library. For this reason the logging interface is often distributed not with the library but in a separate and very small package: libraries bind only to the interface, and applications bind to the package that implements it. Dependencies flow toward the interface, not toward each other.
An Interface Is a Boundary
The Interfaces lesson (M08/K03) showed that an interface is a boundary, not a promise: PHP checks the signature, not the behavior. This lesson ties that distinction to a measurement. First the case where the interface works well, then the case where it does not check.
SP3. The lesson uses the Logger interface not as the whole of PSR-3 but reduced to its single log method. Level names, the exception context and Stringable messages are not measured.
PSR-3: Levels and the Context Array
The PSR-3 interface defines a method for each level and adds a general log method. There are eight levels, from most severe to least: emergency, alert, critical, error, warning, notice, info, debug. The message is a string and may carry placeholders of the form ; the second argument, the context array, supplies the values of these placeholders. In the context, the exception key is reserved for an exception object. The standard does not say which level is written and which is discarded; that is an implementation decision.
The driver below models a small implementation: the severity order is the array order, and a threshold level discards the records below the threshold, that is, the lighter ones. The same four records are written with two different thresholds.
<?php // levels.php — eight levels, a threshold and the context array $levels = ['emergency', 'alert', 'critical', 'error', 'warning', 'notice', 'info', 'debug']; function emit(array $levels, string $threshold, string $level, string $message, array $context): void { if (array_search($level, $levels) > array_search($threshold, $levels)) { return; } $replacements = []; foreach ($context as $key => $value) { $replacements['{' . $key . '}'] = $value; } echo str_pad($level, 10), strtr($message, $replacements), "\n"; } $records = [ ['critical', 'database {server} is not responding', ['server' => 'db1']], ['warning', 'order {number} is late', ['number' => 42]], ['info', 'user {person} logged in', ['person' => 'alice']], ['debug', 'query time {time} ms', ['time' => 12]], ]; foreach (['warning', 'debug'] as $threshold) { echo "-- threshold: $threshold\n"; foreach ($records as [$level, $message, $context]) { emit($levels, $threshold, $level, $message, $context); } }
-- threshold: warning critical database db1 is not responding warning order 42 is late -- threshold: debug critical database db1 is not responding warning order 42 is late info user alice logged in debug query time 12 ms
At the warning threshold only the critical and warning records were written; info and debug were discarded. At the debug threshold all four were written. In both cases the messages came out with the placeholders filled: db1 in place of , 42 in place of . The benefit of writing the message once with a fixed pattern and carrying the variable part in the context is that different implementations can process the same pattern: one can produce plain text, another a structured record.
The Measurement Setup
reach.php is the same as in the earlier lessons: it builds the same program before and after a change and runs both. It asks for the reach in order: if the exit code is not 0 the reach is breaking, if the output differs it is implicit, if the edited file is in the seam list it is at the seam, and if none of these holds it is in place. The earlier version must exit with 0, and the consumer file main.php is not edited.
<?php // reach.php — helpers that measure where a change reaches function write_files(string $dir, array $files): void { foreach ($files as $name => $content) { if ($content === null) { continue; // null: the file was deleted } $path = "$dir/$name"; if (!is_dir(dirname($path))) { mkdir(dirname($path), 0777, true); } file_put_contents($path, $content); } } function read_files(string $dir): array { $files = []; $walker = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS) ); foreach ($walker as $file) { $name = substr($file->getPathname(), strlen($dir) + 1); $files[$name] = file_get_contents($file->getPathname()); } return $files; } function run(string $dir): array { $command = 'cd ' . escapeshellarg($dir) . ' && php -d display_errors=0 main.php 2>/dev/null'; exec($command, $lines, $code); return [$code, implode("\n", $lines)]; } function measure(string $name, array $base, array $change, array $seams = []): string { if (array_key_exists('main.php', $change)) { throw new LogicException("$name: the consumer is not edited"); } write_files("measure/$name/before", $base); write_files("measure/$name/after", array_merge($base, $change)); [$codeBefore, $outputBefore] = run("measure/$name/before"); [$codeAfter, $outputAfter] = run("measure/$name/after"); if ($codeBefore !== 0) { throw new LogicException("$name: the baseline did not run"); } $reach = match (true) { $codeAfter !== 0 => 'breaking', $outputBefore !== $outputAfter => 'implicit', array_intersect(array_keys($change), $seams) !== [] => 'at-seam', default => 'in-place', }; printf( "%-18s files=%d exit=%-3d output %-7s -> %s\n", $name, count($change), $codeAfter, $outputBefore === $outputAfter ? 'same' : 'changed', $reach ); return $reach; }
Changing the Implementation: at the Seam
In the common-interface measurement, the Order class knows only the Logger interface and has it write an order message. setup.php selects which implementation is used; main.php takes it with require and gives it to Order. In the baseline, ScreenLogger prints the message straight to the screen. The change is a single file: setup.php now returns a BufferLogger that collects the messages in a buffer and prints them all at the end of the program. The fourth argument is the seam list and names setup.php as the seam.
Order does not fill the placeholder itself. The interpolate() function that fills the placeholder sits in the same file as the interface, and both implementations call it; Order knows only the log method.
<?php // setup.php change — the implementation changes in the setup file require_once 'reach.php'; // 10. The implementation of a common logging interface is replaced $reaches[] = measure('common-interface', [ 'logger.php' => <<<'PHP' <?php interface Logger { public function log(string $message, array $context = []): void; } function interpolate(string $message, array $context): string { $replacements = []; foreach ($context as $key => $value) { $replacements['{' . $key . '}'] = $value; } return strtr($message, $replacements); } class ScreenLogger implements Logger { public function log(string $message, array $context = []): void { echo interpolate($message, $context), "\n"; } } PHP, 'setup.php' => <<<'PHP' <?php return new ScreenLogger(); PHP, 'main.php' => <<<'PHP' <?php require 'logger.php'; class Order { public function __construct(private Logger $logger) {} public function place(int $number): void { $this->logger->log('order {number} received', ['number' => $number]); } } (new Order(require 'setup.php'))->place(7); PHP, ], [ 'setup.php' => <<<'PHP' <?php class BufferLogger implements Logger { private array $lines = []; public function log(string $message, array $context = []): void { $this->lines[] = interpolate($message, $context); } public function __destruct() { echo implode("\n", $this->lines), "\n"; } } return new BufferLogger(); PHP, ], ['setup.php']); echo run('measure/common-interface/before')[1], "\n"; echo run('measure/common-interface/after')[1], "\n"; $names = array_keys(read_files('measure/common-interface/after')); sort($names); echo implode(', ', $names), "\n";
common-interface files=1 exit=0 output same -> at-seam order 7 received order 7 received logger.php, main.php, setup.php
The first line is the result of the measurement: one file changed, the exit code stayed 0, the output stayed the same, and because the changed file is in the seam list, the reach is at the seam. The next two lines are the output of the two versions of the program: the implementation that writes to the screen and the implementation that buffers gave the same order 7 received line. The last line is the sorted list of files in the directory measure/common-interface/after: main.php is in the list and was never edited during the measurement.
The consequence is this: because the consumer is bound to the interface, Order and main.php stayed in place when the implementation changed. The change was absorbed at the point setup.php. Going from the implementation that prints to the one that buffers was a one-file job, and the output stayed the same.
The Bounding Measurement
An interface checks the signature, not the meaning. One more implementation is applied to the same baseline: its log method satisfies the interface completely as a signature, but it does not use the context and prints the message without filling the placeholder. The driver is a separate measurement and runs under measure/no-placeholder; the baseline is the baseline of the previous measurement.
<?php // no-placeholder.php — an implementation that meets the signature but not the meaning require_once 'reach.php'; $base = read_files('measure/common-interface/before'); measure('no-placeholder', $base, [ 'setup.php' => <<<'PHP' <?php class PlainLogger implements Logger { public function log(string $message, array $context = []): void { echo $message, "\n"; } } return new PlainLogger(); PHP, ], ['setup.php']); echo run('measure/no-placeholder/before')[1], ' -> ', run('measure/no-placeholder/after')[1], "\n";
no-placeholder files=1 exit=0 output changed -> implicit
order 7 received -> order {number} received
The reach became implicit: one file changed and the exit code stayed 0, but the output went from order 7 received to order {number} received. PlainLogger meets the interface completely as a signature; PHP said nothing and the program did not crash. Although the changed file is in the seam list, the reach counts as implicit because the output difference comes before the seam in the order of the checks. The interface protected the boundary, but not the meaning. The promise to fill the placeholder stands only in the documentation and in the text of the common standard; something has to check it, a measurement or a test.
Summary
- The reach of this lesson is at the seam. In the
common-interfaceobservation the implementation changed in the filesetup.php,Orderandmain.phpwere not edited, and the output stayedorder 7 received. - A common interface (such as PSR-3) separates the consumer from the implementation; the change is absorbed at the seam in the setup file.
- The bounding measurement
no-placeholderis implicit: an implementation that meets the signature but does not fill theplaceholder exited with 0, and the output becameorder {number} received. - The library knows only the interface and the application selects the implementation; PSR-3 defines eight levels and a context array with
placeholders, and the implementation decides which level is written. - An interface checks the signature, not the meaning; the meaning needs a separate measurement.
Next Step
The setup file selected the implementation. But if a dependency is code published by someone else and that code is updated, how many files are edited? The Dependency Manager lesson measures this.
To keep your progress and take notes, Log in
My notes
Log in to take notes.