Skip to content

Argument matchers ​

A matcher goes where the argument goes:

php
when(fn () => $repository->find(Arg::any()))->returns($book);

The table ​

MatcherMatches
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:

php
// 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:

php
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:

php
when(fn () => $repo->search(['status' => Arg::any()]));            // refused
when(fn () => $repo->search(Arg::containing(['status' => Arg::any()])));  // this

Arg::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:

php
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.