Named arguments and attributes are the two 8.0 features that get the least attention and change the most about how a library is designed. One turns every parameter name into a public commitment; the other moves a decade of docblock parsing into the engine.
Named arguments, and the boolean trap
// what a call site used to look like
$result = $client->send($request, true, false, null, 30);
// what any of those mean requires opening the method
$result = $client->send(
request: $request,
followRedirects: true,
verifyPeer: false,
proxy: null,
timeoutSeconds: 30,
);
The immediate benefit is readability at the call site, and the more significant one is skipping optional parameters: passing the fifth argument no longer requires supplying the middle three, which removes the main reason those signatures grew an options array instead.
// the pattern this replaces: an untyped bag
public function send(RequestInterface $request, array $options = [])
{
$timeout = $options['timeout'] ?? 30; // a typo is silence
}
// what it becomes
public function send(
RequestInterface $request,
bool $followRedirects = true,
int $timeoutSeconds = 30,
): ResponseInterface
An options array is an untyped bag where a misspelled key is silently ignored, which is a design that existed because named arguments did not. Replacing it with real parameters gets type checking, editor completion and a fatal error on a typo — and the options array is not merely a workaround being retired, it is a workaround that actively hid bugs. A caller passing timeOut got the default and no indication, forever.
The consequence for anybody maintaining a library
before 8.0 a parameter NAME was an implementation detail, and
renaming one was a non-breaking change. from 8.0 it is public:
rename a parameter → breaks every named call
reorder parameters → fine named, breaks positional
insert in the middle → fine named, breaks positional
so a library has TWO compatibility contracts now, and can only
promise both by never touching a signature.This is a genuine and under-discussed cost. A parameter named $db in a constructor that everybody calls positionally is now a name somebody might depend on, and there is no way to deprecate a parameter name — the rename either breaks callers or it does not happen.
The practical response in a library is to treat internal classes as unsafe for named arguments and document it, which is a convention rather than an enforcement. Applications have no such problem and should use them freely.
// the sharp edge, and it is with interfaces
interface Cache {
public function put(string $key, $value, int $ttl = 3600);
}
// legal — PHP does not require matching parameter names
final class RedisCache implements Cache {
public function put(string $k, $v, int $seconds = 3600) {}
}
// so this breaks on one implementation and not the other
$cache->put(key: 'a', value: 1, ttl: 60);
Calling a named argument through an interface is only safe if every implementation uses the same parameter names, and nothing in the language enforces that. It is the one case where named arguments introduce a runtime failure that the type system would normally prevent.
Attributes, which are docblocks the engine understands
// what routing annotations were: a comment, parsed by a
// library with a regex, invisible to every tool
/**
* @Route("/orders/{id}", methods={"GET"}, name="orders.show")
*/
public function show(int $id) {}
// what they become: a real class, checked at compile time
#[Route('/orders/{id}', methods: ['GET'], name: 'orders.show')]
public function show(int $id) {}
The difference is that the attribute is a class reference. A typo in the name is a fatal error rather than an ignored comment, the arguments are type-checked when the attribute is instantiated, and an editor can navigate to the definition — none of which a docblock annotation could ever do.
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class Route
{
public function __construct(
public string $path,
public array $methods = ['GET'],
public ?string $name = null,
) {
}
}
The attribute class is an ordinary class using constructor promotion, which is why these two features arrived together and read as one. The TARGET_METHOD flag is enforced — applying it to a property is an error at reflection time rather than a silently ignored comment.
Reading them, which is where the cost is
$reflection = new ReflectionClass(OrderController::class);
foreach ($reflection->getMethods() as $method) {
foreach ($method->getAttributes(Route::class) as $attribute) {
// NOT instantiated until this call — so an attribute
// referencing a missing class is only an error here
$route = $attribute->newInstance();
$router->add($route->path, $route->methods, [
$reflection->getName(),
$method->getName(),
]);
}
}
The lazy instantiation is deliberate and is what makes attributes cheap when unused: the engine stores the name and arguments and constructs nothing until asked. It also means the validation everybody wants from attributes happens only when something reads them — an attribute naming a class that does not exist is perfectly valid until newInstance is called, which is a weaker guarantee than the compile-time checking the announcement implies.
The reflection scan is the real cost and it is the same cost annotation libraries always had — walking every class in the application on every request is not viable, so this has to be compiled to a cache at deploy time. Frameworks do this and an application rolling its own has to remember to.
$ php bin/console cache:warmup
$ time php -r 'require "vendor/autoload.php"; new RouteScanner()->scan();'
real 0m2.914s # 412 classes, every request
$ time php -r 'require "var/cache/routes.php";'
real 0m0.008sWhere attributes are worth it, and where a docblock was fine
worth converting:
routing, validation constraints, DI wiring, event
listeners, ORM mapping — anything the FRAMEWORK reads
and acts on. a typo there is a bug.
not worth converting:
@param, @return, @var — static analysis reads these and
attributes cannot express generics at all.
@deprecated, @internal — tooling conventions, not behaviour.
and the two coexist. a method can have both, and during a
migration it usually does.The generics limitation is the one that keeps docblocks alive indefinitely: @return list<Order> has no attribute equivalent and no engine support, so the docblock remains the only place to say it. Anybody expecting attributes to replace docblocks entirely is going to be disappointed.
The coexistence is what makes the migration bearable. A library supporting 7.4 and 8.0 reads both, preferring the attribute, which is a dozen lines in the reader and lets the conversion happen at the application’s pace.
What this costs
Named arguments make parameter names a compatibility surface with no deprecation mechanism, which is a permanent constraint on every library that adopts them. That is a cost paid by maintainers on behalf of users, and the users get a genuinely better calling convention — it is a reasonable trade and it is not free.
Attributes need a compilation step to be viable, and the compilation step is a new thing that can be stale. A route added without a cache rebuild produces a 404 that nothing explains, which is the exact failure mode that made people distrust annotation caches in the previous decade — the mechanism improved and the operational hazard did not.