Answers to questions I am asked again and again about PHPUnit, test automation, code coverage, and testing in PHP. Where it helps, an answer links to a fuller article.
Use PCOV when you only need line coverage: it is considerably faster and ideal for CI. Reach for Xdebug when you need branch or path coverage, or when you already run it for step debugging. Enabling both at once rarely makes sense.
Read more in the article: PCOV or Xdebug?
Yes. Collect coverage separately for each run, for example when you shard or parallelise your suite, export it in PHP or Cobertura format, and merge the reports afterwards. This gives you one combined figure without forcing every test into a single run.
Read more in the article: Merging code coverage data
Line coverage only tells you which code was executed, not whether your assertions actually check anything. Path coverage reveals untested combinations of branches, and mutation testing measures whether your tests would even notice a change to the code. Both expose confidence that coverage alone cannot.
Read more in the article: Path Coverage or Mutation Testing?
withConsecutive() was removed in PHPUnit 10. Replace it with a single willReturnCallback() that inspects the arguments, combined where needed with the invocation matcher returned by $this->exactly() so you can react to each call by its number. It is more verbose but far clearer about what each invocation expects.
Read more in the article: Better than withConsecutive()
Use a stub when you only need a collaborator to return canned values so the code under test can run. Use a mock when the interaction itself is what you want to verify. Verifying calls that do not matter couples your tests to the implementation and makes them brittle.
Read more in the article: The Stub/Mock Intervention
Ask yourself what you need the test double for. If the call matters, replace any() with a specific matcher such as once() or exactly(2). If it does not matter, you do not need a mock object at all: create a test stub with createStub(). expects($this->any()) says that you want to verify communication but do not care whether it happens, and that contradiction is the reason for the deprecation.
Read more in the article: From anti-pattern to clarity
No. A value object stands for a fixed value, so there is nothing to isolate the code under test from. Use real instances and you test against the same behaviour that production gets. The same applies to DTOs, especially when you design them to be immutable.
Read more in the article: Testing with DTOs and Value Objects
There are four levels to work through: the test runner configuration, the infrastructure your tests touch, parallelisation, and the design of the tests themselves. The biggest and most lasting gains almost always come from better test design, not from throwing more cores at a slow suite.
Read more in the article: Turbo-Charging Your PHPUnit Suite
PHPUnit knows how long every test took, how much CPU time it used, and how much memory it needed. With --log-otr it writes that data into an Open Test Reporting log file. The otr-report tool reads the file back and lists the slowest tests, optionally only those above the mean runtime. That is almost always more honest than intuition.
Read more in the article: What your test run already knows
Measure before you change anything. A profiler shows you where time and memory actually go, which is regularly somewhere other than where you suspected. Guessing and optimising by intuition tends to make the code more complex without making it faster.
Read more in the article: Debugging Performance in PHP
No. setUp() runs before every test in the class, including the tests that never touch the fixture it creates. Once inheritance is involved, a forgotten parent::setUp() call quietly disables whatever the parent class provides. I write private methods in the test class that create the objects instead, and call them only from the tests that need them. The test holds no reference afterwards, and each test makes its own dependencies visible.
Read more in the article: How I manage test fixture
With characterization tests. They document what the code actually does without judging whether that behaviour is right: write a test with an assertion you know will fail, read the actual result off the failure message, and put that value into the assertion. Repeat with different inputs until you have enough coverage. That gives you the safety net you need before you break the code into smaller, testable units.
Read more in the article: A flight recorder for your code
Both run one test method against many inputs, but they answer different questions. A data provider records concrete examples: given this input, expect exactly this result. A property states something that must hold for all inputs and leaves the examples to a generator. Data providers document what you already know; properties expose the gaps in it.
Read more in the article: Data Provider or Properties?
Instead of checking individual examples, you state a property that should hold for all valid inputs and let a tool generate many of them, including edge cases you would not think of. When it finds a failure, it shrinks the input to the smallest case that still breaks.
Read more in the article: Property-Based Testing
Whatever decides that is the test oracle. For a return value, the oracle is the comparison with the expected value. For a state change it is the check you run after the call, and for communication with collaborators it is the expectations you configured on your mock object. If you cannot say what the oracle in a test is, that test may not be checking anything.
Read more in the article: Seeing the Truth: Test Oracles
Ideally not. The best bug fix adds a regression test and rewrites no existing expectation: the new test proves the bug is gone, the untouched ones prove that nothing else broke. If a fix does force you to rewrite an existing expectation, stop and look. Usually one of three patterns applies: the test was coupled to implementation details, the fix is really a behaviour change and should be communicated as one, or you are re-arming a test that was silenced earlier.
Read more in the article: Untouched tests are half the proof
Wrap an array in a dedicated class that only accepts the type you want, expose the operations you actually need, and let static analysis verify the element type through generics annotations. You gain safety and a clear API instead of passing raw arrays around.
Read more in the article: Type-Safe Collections
I use PHPStan, mostly for its large ecosystem of extensions for frameworks and libraries, and it is what I recommend to anyone new to static analysis. Running both tools on the same code is not something I recommend: their rules can contradict each other, and you end up trying to satisfy two different opinions about good code. Which tool you pick matters far less than deciding to use static analysis at all.
Read more in the article: Psalm or PHPStan?
For coding standards I use PHP-CS-Fixer: everything this tool can complain about it can also fix automatically. Locally I let it fix the code before a commit, in CI it runs in check mode and only reports deviations. I still use PHP_CodeSniffer where I analyse code rather than format it, for due diligence, security audits, and guided refactoring, with my own sniffs and rule sets such as PHPCompatibility.
Read more in the article: PHP_CodeSniffer or PHP-CS-Fixer?
No single technique is enough. Static analysis finds type errors and inconsistencies without running the code. Unit tests check specific behaviour, property-based testing generalises it, mutation testing measures how good your tests are, and fuzzing explores what nobody thought of. Each layer covers a different slice of the infinitely many ways code can be wrong.
Read more in the article: Everything we have
For almost every vulnerability that reaches production, there is a test which, had it existed, would have prevented it. Test-Driven Security treats known weakness categories as a checklist and uses the testing tools you already have to prove that each class of flaw is absent.
Read more in the article: Test-Driven Security
It can. If your suite is too slow to run often, automated tools and AI agents cannot use it to check their changes either, so vulnerabilities stay hidden longer. What used to be framed purely as a productivity problem is now part of the security conversation.
Read more in the article: Speed as a security feature
No. PHPUnit is a development dependency: require it under require-dev and make sure it never ends up in your production deployment or autoloader. Shipping test tooling to production needlessly enlarges your attack surface.
Read more in the article: PHPUnit: A Security Risk?
Since Composer 2.9 the resolver does it for you: versions with known security advisories are no longer considered at all, by default, every time the dependency graph is resolved. Before that, composer audit only informed you after the fact, and a hard guarantee required a package such as roave/security-advisories. Expect this to reach you in places where the passive audit merely informed you: an install can now fail because a vulnerable version is no longer resolvable.
Read more in the article: The Bouncer in the Dependency Resolver
Treat a workflow as code that touches secrets. The most common and most dangerous mistake is interpolating a ${{ ... }} expression into a shell script: the value is substituted before the shell ever reads the script, so a branch name freely chosen in a fork becomes executed code. Pass such values through environment variables and quote them in the script. A static analyser for workflow files such as zizmor finds this and related weaknesses in minutes.
Read more in the article: Hardening GitHub Actions workflows
Usually it does not, but that is not guaranteed. PHPCSUtils had a vulnerability (CVE-2026-65954) where a sniff passed the source text of an array key to eval(). A prepared array key was therefore executed while the file was being analysed, for example in the CI job that checks every pull request. Keep your analysis tools up to date. disable_functions does not help here: eval() is a language construct and has no entry in the function table.
Read more in the article: When static analysis runs your code
A new major version of PHPUnit is released every year on the first Friday of February and is where the cleanup happens. What it changes is announced a year earlier, with the release of the previous major version. Read that announcement and schedule the necessary changes across the year, and the upgrade holds no surprises. Do not let a testing tool update itself to a new major version unattended.
Read more in the article: Help! My tests stopped working
PHPUnit distinguishes two levels. A soft deprecation produces no output during a test run, but IDEs and static analysis tools can already warn about it. A hard deprecation is reported on every test run. Your tests keep working in both cases, and a hard-deprecated feature is removed no earlier than the next major version.
Read more in the article: From anti-pattern to clarity
Green tests show that the code does what the tests check, not that it does the right thing. I had an AI agent implement a non-trivial software metric. It took fifteen minutes; judging whether the implementation was correct then took me hours. Writing code was never the bottleneck: understanding the problem, designing the solution, and checking the result are the hard parts.
Read more in the article: Faster than understanding
The same things that help human teams, only less optional: a test suite you trust, static analysis, maintained documentation, and code review. An agent can analyse the codebase, write an implementation and its tests, run them, and prepare a pull request with nobody watching. Those practices are the frame that makes such autonomy reviewable.
Read more in the article: Beyond Best Practices
Whenever you have not understood the problem yet. A line you do not write is a line nobody has to debug, maintain, test, or understand again in five years. That applies with particular force to code an AI agent produces in minutes: if you cannot judge whether it is correct, your maintenance burden grows without you knowing what you got for it.
Read more in the article: Code I do not have does not cause any problems
Yes. I offer tailored workshops and training on PHPUnit, test automation, and testable software design, delivered remotely or on site. See the training section for the current topics.
That is a large part of what I do. Through consulting and coaching I help teams bring legacy code under test and build the habits that keep it that way. The consulting section describes how that works.