Argument matchers
A matcher goes where the argument goes:
when(fn () => $repository->find(Arg::any()))->returns($book);The table
| Matcher | Matches |
|---|---|
Arg::any() | anything, including null |
Arg::int(min:, max:) | an int in range — a numeric string does not match |
Arg::float(min:, max:) | a float in range — an int does not match |
Arg::string(matches:) | a string, optionally against a PCRE pattern |
Arg::bool() | a boolean |
Arg::same($v) | strict identity; for objects, the same instance |
Arg::not($v) | negates a literal or another matcher |
Arg::allOf(...) | everything the operands accept; an operand is a matcher or a literal |
Arg::anyOf(...) | anything at least one operand accepts, so anyOf('draft', 'review') reads as a set |
Arg::instanceOf($class) | an instance of the class or interface |
Arg::satisfies($fn) | whatever the predicate accepts |
Arg::containing($entries) | an array holding these entries and possibly more; an entry may itself be a matcher |
Arg::count(minimum:, maximum:) | an array or Countable of that size |
Arg::which($method, $value) | an object whose getter answers this value |
Arg::none() | an empty variadic tail — last argument only |
Arg::remaining() | the whole variadic tail, any length — last argument only |
Arg::rest() | declared parameters left unspelled — last argument only |
There is also Arg::captor(), which matches and records at the same time.
An optional parameter needs no matcher
The contract says a caller may leave it out, so a specification that leaves it out says nothing about it — and matches whatever the call passed there:
// claimReady(DateTimeImmutable $t, int $max, array $kinds = [], int $limit = 1000)
verify(fn () => $storage->claimReady(Arg::any(), Arg::any(), Arg::any()), times: 1);matches claimReady($now, 3, [], 100). A failure message renders a position the specification never mentioned as …, so the report distinguishes it from an any() the test did write: claimReady(any(), any(), any(), …).
A required parameter is a different claim — every real call carries one — so stopping before it still has to be said with rest() below.
The type matchers are strict on purpose
Arg::int() rejects '5'. Arg::float() rejects 1.
A matcher pins the declared type as much as the value, which is the point in a codebase running under strict_types. A matcher that quietly accepted the other type would hide exactly the bug the declaration exists to prevent.
A matcher that could never match is refused
Arg::int(min: 5, max: 1) — and the same shape in Arg::float() and Arg::count() — describes an empty range, Arg::string('/[unclosed') is not a pattern PCRE compiles, and Arg::instanceOf('Nope\Missing') names a type nothing can be an instance of. Each used to build a matcher that answered "no" to every argument, so the mistake surfaced as an expectation never met, with nothing pointing at its cause. They are refused with InvalidSpecificationArgument where they are written.
The broken pattern had a second cost: preg_match() raises a warning on every call, from inside the code under test — which is the one thing a matcher must never do.
A pattern that does compile is yours and is used as written, PCRE semantics included: $ matches before a trailing newline, so Arg::string('/^ord-\d+$/') accepts "ord-1\n". Anchor with \z (or add the D modifier) where that matters.
rest() against remaining()
They look similar and stand for different things:
| Stands for | |
|---|---|
Arg::remaining() | the variadic tail the method declares — any length |
Arg::rest() | "the arguments written here matter, the rest of the arity does not" |
rest() is the one matcher that lets a specification stop before the method's required parameters run out — an optional one needs nothing:
when(fn () => $storage->recordOutcome('svc', Arg::rest()))
->throws(new RuntimeException('storage unavailable'));A specification that stops before a required parameter without ending in Arg::rest() is refused with the reason, rather than becoming a stub that silently never matches. A later, narrower specification for the same call still wins over the broad prefix stub.
One thing to know
A static analyser reads the shortened call against the contract's arity, so expect a "too few arguments" diagnostic on that line until your analyser knows the idiom. The Psalm plugin and the PHPStan extension teach it.
A matcher inside an array argument
An array argument is compared by identity, so a matcher buried in one would match nothing and say nothing about it. It is refused where it is written, and the message names what to use instead:
when(fn () => $repo->search(['status' => Arg::any()])); // refused
when(fn () => $repo->search(Arg::containing(['status' => Arg::any()]))); // thisArg::containing() reads matchers in its own entries, nested as deep as the payload goes.
Arg::which() and a getter that throws
Arg::which() calls only a public, non-static method that needs no arguments.
A getter that throws counts as a mismatch, never as an error. Matching runs while the code under test is executing, and a matcher must not be the thing that breaks it.
where(): a constraint over several arguments
A positional matcher sees one argument. A constraint over two or more of them together — transfer($from, $to) where $from <= $to must hold — is what where() is:
when(fn () => $ledger->transfer(Arg::any(), Arg::any()))
->where(fn ($from, $to) => $from <= $to)
->returns(true);
verify(
fn () => $ledger->transfer(Arg::any(), Arg::any()),
times: 1,
where: fn ($from, $to) => $from <= $to,
);The closure receives the arguments the call carried, positionally, so it reads like the method's own signature. It is asked only after every position already matched, and a call it refuses simply does not match — a broader expectation can still answer it. expect() takes one through the same ->where().
The guard runs wherever matching runs, which is inside the code under test: it is the test's own code, and an exception it raises is the test's own error. One guard per expectation — a second where() is refused rather than silently replacing the first. In a failure message the guard renders as where(…), or as the description given, and when the positions accepted a call but the guard refused it, the report marks the guard itself — transfer(any(), any()) *where(…)* — rather than blaming a position that did accept.
A matcher that never reaches a specification
Building a matcher and not passing it to a specification is reported, not ignored — MatcherLeaked. The static analysis packages report the same thing before the test runs, under understudy.matcherLeak.