skip to content

Architecture & Plugins

Pest's arch() tests enforce rules like toUseStrictTypes and presets such as security, while plugins add --parallel, --mutate and type coverage. Interviewers probe what each adds to a suite.

on this pageshow

explore

questions

6

In Pest, how would you write an arch() test that forbids controllers from using the database layer directly?

level: middleimportance: should knowfreq 30%

answer

  1. a test about dependencies, not behaviour
  2. expect() takes a namespace string
  3. not->toUse() on the consumer side
  4. toOnlyBeUsedIn() on the provider side
  5. ignoring() for the one legacy exception

basics

~10 s

Write arch()->expect('App\Http\Controllers')->not->toUse([...]) naming the database namespace and classes such as PDO, or turn it around with arch()->expect('App\Database')->toOnlyBeUsedIn('App\Repositories'); Pest fails the test listing every file that breaks the rule.

solid answer

~40 s

An `arch()` test in Pest asserts a rule about what code *references*, not what it returns. The consumer-side form is `arch()->expect('App\Http\Controllers')->not->toUse(['PDO', 'App\Database'])`: any controller file that references `PDO` or anything under `App\Database` fails the test. The provider-side form is `arch()->expect('App\Database')->toOnlyBeUsedIn('App\Repositories')`, a whitelist of who may depend on the layer, which also catches a new console command or job reaching for it. Both forms check direct references in the target files only, so a controller that calls a service that uses `PDO` passes. Known exceptions go through `->ignoring('App\Http\Controllers\LegacyReportController')` instead of weakening the rule, and the test runs with the rest of the suite, so CI enforces the boundary on every push.

code

php · 9 lines
php
<?php

arch('controllers stay off the database layer')
    ->expect('App\Http\Controllers')
    ->not->toUse(['PDO', 'App\Database']);

arch('only repositories reach the database layer')
    ->expect('App\Database')
    ->toOnlyBeUsedIn(['App\Repositories', 'App\Database']);

go deeper

for a junior

Recall that arch() starts an architecture test, expect() takes a namespace string, and not->toUse() forbids a dependency. Being able to write one such rule from memory is enough.

for a middle

Explain the difference between the consumer-side not->toUse() rule and the provider-side toOnlyBeUsedIn() whitelist, and why only direct references are checked.

for a senior

Show how you would roll boundaries out on an existing codebase: provider-side rules for coverage, ignoring() for known offenders tracked as debt, descriptive names so CI failures are self-explanatory.

for a principal

Discuss which architectural decisions deserve an executable rule, how many rules a team can maintain, and how exceptions are reviewed so the rules keep their authority.

## What an arch() test is An **architecture test** in Pest is a test that inspects the *structure* of your code rather than its behaviour. You start it with the `arch()` function instead of `test()` or `it()`, point it at a namespace (or a class, or a function name) with `expect()`, and chain one or more **arch expectations** such as `toUse()`, `toOnlyUse()`, `toOnlyBeUsedIn()`, `toExtend()` or `toBeFinal()`. Architecture testing relies on `pestphp/pest-plugin-arch`, a required dependency of Pest 5, so no extra install is needed. The target strings are namespaces like `'App\Http\Controllers'`, fully qualified class names like `'PDO'`, or global function names like `'mysqli_query'`. Since Pest 3.8 a target may also contain wildcards, such as `'App\*\Traits'`. ## Two ways to state "controllers must not touch the database" The rule can be written from either side of the dependency, and the two are not equivalent. | Form | Reads as | Catches | |---|---|---| | `expect('App\Http\Controllers')->not->toUse([...])` | these consumers may not reference those things | controllers only | | `expect('App\Database')->toOnlyBeUsedIn([...])` | only these namespaces may reference the layer | every other namespace, including new ones | | `expect('App\Http\Controllers')->toOnlyUse([...])` | these consumers may reference nothing else | controllers, against a strict allow-list | - The **consumer-side** rule (`not->toUse`) is a blacklist written about controllers. It is easy to read in a code review, but a queue job or a console command that opens `PDO` directly is outside its target and passes. - The **provider-side** rule (`toOnlyBeUsedIn`) is a whitelist written about the database layer. Anything outside the listed namespaces that references the layer fails, which makes it the stronger boundary. - `toOnlyUse()` is the strictest: the target may depend *only* on what is listed. It suits a small domain namespace; on controllers it tends to produce a long allow-list. Not every expectation can be negated: `not->toOnlyBeUsedIn()` throws an `InvalidExpectation` error in Pest 5, and `toBeUsed()` only works in its negated form, `not->toBeUsed()`. ## What the check sees, and what it does not 1. It looks at the files of the target namespace and the names they reference: imported classes, fully qualified names, global functions. 2. It does **not** follow calls. A controller that calls `OrderService::place()`, which in turn uses `PDO`, passes a `not->toUse('PDO')` rule on controllers. The layering rule has to be written for every layer that matters, not just the top one. 3. It does not execute your controllers, so it needs no database, HTTP kernel or fixtures, and it is cheap enough to run on every commit. ## Exceptions without eroding the rule Real codebases have a legacy class that breaks the rule today. The `ignoring()` modifier excludes named namespaces or classes from one expectation: ```php arch('controllers stay off the database layer') ->expect('App\Http\Controllers') ->not->toUse(['PDO', 'App\Database']) ->ignoring('App\Http\Controllers\LegacyReportController'); ``` The exception is now visible in the test file, reviewable, and removable in a later refactor. That is better than dropping the whole rule or moving the offender into another namespace to dodge it. ## Growing one rule into a layering set One boundary rarely describes an architecture. Teams usually end up with a handful of small rules, each stating one dependency direction: - `expect('App\Http\Controllers')->not->toUse(['PDO', 'App\Database'])` keeps HTTP code off persistence. - `expect('App\Domain')->not->toUse('App\Http')` keeps the domain free of request and response types. - `expect('App\ValueObjects')->toUseNothing()` states that value objects depend on nothing at all. - `expect('App\Database')->toOnlyBeUsedIn(['App\Repositories', 'App\Database'])` closes the persistence layer from the provider side. Written this way, a failure names exactly which direction was violated and in which file, so the fix is obvious from the CI log. One large rule that tries to express the whole architecture fails with a list nobody can act on. ## Where these tests live in a suite - Put them in an ordinary test file, for example `tests/Architecture/LayersTest.php`, so they run with every `./vendor/bin/pest` invocation and in CI. - Give each `arch()` call a description, `arch('controllers stay off the database layer')`, because the description is what the failure output and the CI log show. - Keep rules small and single-purpose: one boundary per `arch()` call gives one clear failure message listing the offending files. - Pair a layering rule with a naming or type rule when it helps, for example `expect('App\Http\Controllers')->toHaveSuffix('Controller')`. The value interviewers look for is that the boundary stops being a convention written in a wiki and becomes a failing test the moment someone adds `new PDO(...)` to a controller.

  • A controller calls a service that uses PDO; does the not->toUse('PDO') rule on controllers catch it?
    No. Arch expectations check what the target files reference directly, and the controller only references the service. To enforce the layering fully you add a rule for the service layer as well, or state the boundary from the provider side with `toOnlyBeUsedIn()`, which fails for any namespace outside the whitelist that references the database layer.
  • How do you stop one legacy controller from failing the rule without deleting the rule?
    Chain `->ignoring('App\Http\Controllers\LegacyReportController')` onto the expectation. The class is excluded from that one rule only, the exception stays visible in the test file, and removing the line later re-arms the check once the class is refactored.

saying these in an interview costs you the question

  • Arch tests execute the controllers to see which queries they run
  • not->toUse() on controllers also catches calls made through a service
  • Any arch expectation can be negated with not, including toOnlyBeUsedIn()
  • Moving the offending class to another namespace is the right way to pass
  • Arch rules need a separate tool outside the Pest suite to run in CI
open as a page

What do Pest's --parallel and --tia options each do to a slow suite, and why does only one belong in the CI command?

level: middleimportance: should knowfreq 35%

basics

~20 s

--parallel runs the whole suite across several processes through ParaTest, so it belongs in CI; --tia (Pest 5) records which files each test touches and later runs only affected tests, replaying cached results, so it is meant for local runs.

open as a page

In Pest, what do the --mutate option and the covers() and mutates() functions do together, and how do you gate CI on the result?

level: seniorimportance: should knowfreq 28%

basics

~20 s

--mutate makes Pest mutate the classes named by covers() or mutates() in your test files, rerun the tests that cover each mutation, and report which survived as untested; --mutate --min=N fails the run when the score is below N percent.

open as a page

Your team maintains a large PHPUnit 13 suite; how would you decide whether to adopt Pest 5, and how would you roll it out?

level: principalimportance: should knowfreq 22%

basics

~20 s

Pest 5 runs on PHPUnit 13, so existing TestCase classes keep running and phpunit.xml still applies; adopt it for what it adds (arch rules, built-in mutation, type coverage, Tia) against its costs: a PHP 8.4 floor and PHPUnit upgrades tied to Pest releases.

open as a page

What does Pest's --type-coverage option measure, and how does it differ from running Pest with --coverage?

level: middleimportance: nice to knowfreq 18%

basics

~10 s

--type-coverage, from pestphp/pest-plugin-type-coverage, reports the percentage of parameters, return types and properties that carry type declarations, analysing the code without running tests; --coverage runs the tests and reports which lines executed.

open as a page

What do Pest's php, security and strict arch presets each enforce, and how do you exempt one function from a preset?

level: seniorimportance: nice to knowfreq 20%

basics

~10 s

arch()->preset()->php() bans debug and output calls such as var_dump and die, security() bans risky calls such as eval, md5 and unserialize, and strict() demands declare(strict_types=1), strict equality and final classes; ->ignoring('md5') exempts one entry.

open as a page