
What maintaining open-source PHP software teaches you about API design
Learn PHP API design lessons from maintaining Dawn: compatibility contracts, explicit failures, release discipline, and tests that protect users.
PHP API design looks simple when you own both sides of the code. You rename a method, update its callers, run the test suite and move on. Maintaining open-source PHP software removes that comfort. The callers live in repositories you cannot see, use combinations you did not predict and depend on details you may not have considered part of the API.
I learned this while building and releasing Dawn, a PHP package that reimplements Laravel Dusk's public Browser API on Playwright. Its promise sounds narrow: existing Dusk test bodies should keep working while the browser engine changes underneath them.
That promise made API design unusually concrete. A method with the right name was not enough. Its parameters, return value, waiting behavior, exception message, class name and interaction with Laravel all mattered. Code could be locally elegant and still be incompatible.
The first releases taught me that a public API is not the set of functions you intended to expose. It is the set of expectations users can reasonably build on.
Your public API is larger than your method list
Package authors naturally start with symbols: public classes, methods, interfaces and configuration keys. Those are visible and easy to document. They are only the outer edge of the contract.
Behavior is part of the API. If a method waits until an element becomes actionable, callers can write a test without an extra pause. If the same method suddenly becomes a point-in-time check, the signature has not changed, but the user's application can start failing.
Names are part of the API even when they point to equivalent implementations. Dawn exposes its own classes, but migrated Dusk suites often contain type hints such as Laravel\Dusk\Browser, extend Laravel\Dusk\Page, or construct Laravel\Dusk\Keyboard. Supporting only the main browser class was not enough. Those names appear in user code, so they belong to the compatibility surface.
Operational details can become API, too. Dusk users already collect screenshots and console logs from known directories in CI. Dawn keeps those artifact paths. Moving the files might look like internal cleanup, yet it would break pipelines that upload them after a failed test.
Error behavior is another contract. Does an unsupported operation return null, silently do something approximate, emit a warning or throw a typed exception? Each choice shapes the caller's control flow.
When I evaluate a PHP package API now, I ask about five surfaces:
- Syntax: names, signatures, types and defaults.
- Semantics: what the operation means and when it is complete.
- Failure: how unsupported input and runtime errors appear.
- Operations: files, commands, environment variables and lifecycle hooks.
- Ecosystem: supported PHP, framework and dependency combinations.
A change can preserve the first surface while breaking any of the other four.
Compatibility must be measurable
“Mostly compatible” is easy to claim and difficult to use. A user needs to know whether the specific methods in their suite are supported and what happens at the edges.
Dawn keeps a method-by-method compatibility table. Navigation, input, mouse operations, waiting, assertions, authentication, cookies and Vue helpers are listed explicitly. Methods that cannot map to Playwright are listed with reasons rather than hidden in a percentage.
The percentage itself is generated by a script that compares Dawn with the current upstream Dusk API. A weekly workflow refreshes the compatibility badge. That matters because the target moves. A package can remain unchanged while its compatibility declines after the upstream project adds a method.
This turns compatibility from marketing language into a testable question:
upstream public methods
- faithfully implemented methods
- explicitly unsupported methods
= unexplained compatibility gaps
The goal is not necessarily 100 percent. Dawn deliberately does not support Dusk operations that depend on an OS-level browser window or an interactive PHP session. The goal is zero unexplained gaps.
The same idea applies to an API you design from scratch. Write the contract in a form that can be compared with the implementation. That may be an OpenAPI document, an interface test suite, a command snapshot or a compatibility table. Documentation that cannot reveal drift eventually becomes a historical description.
Explicit failure is part of good API design
An adapter often faces a tempting choice: imitate an operation approximately or refuse it. Approximation can make a compatibility table look greener, but it transfers uncertainty to the user.
Playwright uses browser viewports and often runs headlessly. Dusk's maximize() and move() methods describe OS windows. Dawn could turn them into no-ops, but then a passing test would suggest that an action occurred when it did not. Instead, unsupported methods throw UnsupportedDuskMethod, naming the method and linking to the explanation.
This is more useful than a generic exception and safer than pretending. A developer can identify the exact incompatibility, decide whether it matters and replace it deliberately.
The principle extends beyond adapters. If a value cannot be represented faithfully, say so at the boundary. If a configuration combination is invalid, reject it before work begins. If a feature is best-effort, make that property visible in its name, return type or documentation.
Silent approximation creates delayed failures. The original call appears successful, while the consequence emerges elsewhere with less context. Explicit failure keeps cause and diagnosis close together.
Small compatibility fixes expose large contracts
Dawn's v0.3.1 release contained several small fixes. Each exposed a different kind of public contract.
Class aliases are not implementation trivia
The initial compatibility layer aliased the most obvious Dusk classes. Page objects and components revealed the missing surface. A migrated suite could extend Laravel\Dusk\Page or accept a Laravel\Dusk\Keyboard in a closure even though its test steps otherwise worked.
The fix added aliases for Page, Component and Keyboard, then expanded the compatibility test to cover every alias. The lesson is that a public type can matter without being instantiated by the package's main execution path. User code may reference it through inheritance, reflection, dependency injection or a callback signature.
Input grammar is an API
Dawn's element resolver decides whether a string is a plausible CSS selector. Escaped selectors such as Tailwind's .md\:flex contain backslashes. A filter that rejected backslashes could silently discard a valid candidate before Playwright saw it.
The correction allowed valid escapes while still rejecting a dangling trailing backslash. This was not a new method or a changed signature. It was a change to the language accepted by an existing argument.
Every string parameter has an implied grammar: a path, selector, identifier, expression, date or mini-language. Document and test that grammar like any other interface.
Construction patterns are part of compatibility
Keyboard worked through Dawn's withKeyboard() flow, but Dusk also allowed direct construction with a Browser. Accepting only the internal Playwright page type made that valid user pattern fail with a TypeError.
The fix accepted both construction paths. It is a reminder that integration tests following your preferred documentation are not enough. Users compose public objects in ways that remain valid even if they are not the path you expected them to choose.
These are the bugs maintainers need to welcome. They reveal where the real contract is wider than the mental model used to design it.
Dependency ranges are product decisions
A PHP library's API includes the environments in which Composer can install it. Supporting Laravel 13 required more than changing one version constraint. Dawn added illuminate/support:^13.0, the corresponding Testbench version, a PHP 8.4 integration job and verification against a real Laravel application.
PHPUnit exposed an even less obvious dependency contract. Dawn calls PHPUnit\Framework\Assert at runtime, so PHPUnit is not merely a development dependency for the package's own tests. A restrictive version cap could prevent installation in an otherwise compatible Laravel 13 project.
The changelog records why PHPUnit 13 support mattered and what combination was verified. That explanation is more valuable than “update dependencies.” It tells a user which blocked scenario the release fixes.
Broad constraints without test coverage are optimism. Narrow constraints without a technical reason create unnecessary conflicts. A useful support matrix names framework and PHP versions, runs them in CI and includes at least one real-application test where package discovery, routes, cookies and the framework lifecycle can behave differently from an isolated unit suite.
Semantic Versioning says version numbers communicate changes to a declared public API. Composer constraints then automate decisions from that signal. Neither mechanism can compensate for an undefined API or an untested support range.
Test the user's contract, not your implementation
Implementation-shaped tests make refactoring expensive while still missing compatibility. Contract-shaped tests do the opposite: they permit internal change but fail when the user's experience changes.
Dawn includes acceptance tests whose bodies are byte-identical to Dusk tests. It also ports selector formatting and URL assertion cases from Dusk's own suite. Those tests do not ask whether Dawn's internal classes look elegant. They ask whether the same user code produces the expected behavior.
The package also tests real browser behavior without sleeps, runs PHPStan at its maximum level and exercises real Laravel applications across supported versions. Each layer protects a different contract:
| Test layer | Contract it protects |
|---|---|
| Unit tests | Parsing, formatting, messages and narrow edge cases |
| Contract tests | Compatibility with existing Dusk test bodies |
| Real-browser tests | Timing, locators and browser interaction |
| Framework matrix | Supported PHP and Laravel combinations |
| Static analysis | Type expectations inside the package |
A mock can prove that your adapter called something. It cannot prove that a browser waited correctly or that a Laravel service provider registered only in the intended environment. Testing at the contract boundary is the same habit I use when I test AI-generated code before trusting it: verify observable behavior, not the confidence of the implementation.
Error messages deserve contract tests as well. Developers read them during failure, and external tools may group or recognize them. Changing a precise compatibility error into a generic exception can be a regression even when both paths technically throw.
Release notes are part of the API
A changelog should help a user answer one question: should I upgrade, and what should I verify afterward?
“Fixed compatibility” is too vague. “Alias Laravel\Dusk\Page, Component and Keyboard so migrated page-object test bodies resolve without Dusk installed” identifies the affected pattern and expected outcome.
Good release notes record additions, changes, fixes and known limitations in user language. Link releases to diffs. State when behavior deliberately diverges. Keep unreleased changes visible so documentation evolves with code rather than being reconstructed later.
Before 1.0, Semantic Versioning allows faster evolution, but users still deserve clarity. A 0.x number is not permission to make surprising changes without explanation. Trust grows when the version, changelog, tests and documentation tell the same story.
Seven questions before publishing a PHP API
- What exactly is public? Include names, behavior, errors, commands, configuration and operational outputs.
- Which upstream or downstream contract constrains it? Framework behavior, a protocol, an earlier package version or user code may be the real specification.
- Can compatibility be measured? Build a contract suite, comparison script or explicit table.
- What cannot be supported faithfully? Reject it clearly instead of approximating in silence.
- Which input grammars need edge-case tests? Escapes, Unicode, paths, selectors and empty values often reveal the actual boundary.
- Which environment combinations are promised? Test every declared PHP, framework and critical dependency range.
- Can a user understand the next release? Make the changelog explain impact, not merely implementation activity.
This checklist is deliberately broader than method signatures. Most painful compatibility breaks happen in the assumptions around the obvious interface.
Maintenance is downstream-driven design
Designing a new API is mostly an exercise in prediction. Maintaining one is an exercise in evidence.
A page object that fails to resolve, an escaped selector that disappears or a dependency range that blocks installation tells you exactly where the contract differs from your model. The maintainer's job is not to defend the original abstraction. It is to decide whether the user's expectation is reasonable, make the behavior explicit and protect that decision with a test.
That is what maintaining open-source PHP software has taught me about API design: the best interface is not the one with the fewest public methods. It is the one whose promises are visible, testable and honest.
If you want the earlier story behind Dawn and my first upstream contribution, read why open source matters. For more about my engineering work, see about me.
Project facts and links were checked against Dawn's public repository on September 11, 2026. Technical references: Dawn's compatibility table, changelog, contribution rules, and Semantic Versioning 2.0.0. Cover: code photograph from Pixabay.
