codeception/assert-throws
Assert that a callable throws the expected exception without try/catch boilerplate. codeception/assert-throws adds assertThrows and assertDoesNotThrow helpers for Codeception and PHPUnit tests, with clear failure messages and optional checks on exception class and message.
Architecture fit. This is a test-layer-only utility: a trait (AssertThrowsTrait) that adds four assertion methods (assertThrows, assertThrowsWithMessage, assertDoesNotThrow, assertDoesNotThrowWithMessage) to PHPUnit/Codeception test classes. It ships no service provider, no config publish, no facade, no runtime service — so it has zero production footprint in a Laravel application and never enters the production autoload path. Architecturally it sits beside Laravel's testing helpers (Illuminate\Foundation\Testing\TestCase, HTTP assertions, assertDatabaseHas) rather than replacing them, which is the correct placement. Its single differentiator over PHPUnit's native expectException() is the non-halting behavior: you can assert several exception paths in one test and keep asserting afterwards.
Integration feasibility. Very high. One composer require --dev, one use statement in a project base TestCase that already extends Laravel's testing TestCase — every unit/feature test then inherits the assertions with no per-file boilerplate. No PHP extension, no internal API coupling, no compiled artifacts. Realistic effort: under one developer-day for install plus pilot, and one to two sprints of opportunistic migration to standardize patterns. Reversal cost is deleting one dependency and one trait import.
Technical risk. Low blast radius, but non-trivial adoption risk — this is a policy decision dressed as a dependency:
try/catch. Frame it as “cheap, forkable convenience,” not a strategic dependency; the honest value being purchased is someone else tracking PHPUnit majors (releases show a clean ladder: 1.3.0 → 10, 1.4.0 → 11, 1.5.0 → 12, 1.6.0 → 13).composer.json before merge.expectException() may misread a test that keeps running. This needs a written convention, not just a demo.expectException() and half uses assertThrows(), reviewers lose a consistent mental model of what a failing exception test looks like.Key questions.
TestCase do Laravel feature tests actually inherit?expectException() suffices) or “multiple exception paths per test” (this package earns its place)?->throws() covers the main case idiomatically and a third-party trait is friction with no payoff.ValidationException, ModelNotFoundException, AuthorizationException, HttpResponseException)?expectException / hand-rolled try/catch in tests, count of tests asserting a specific exception class, and error-path coverage on high-risk modules.Stack fit. Laravel 8+ on PHP 7.4+ (PHP 8.x in practice), PHPUnit 9–13, Codeception 4+ if used. The cleanest insertion point is a project base TestCase that already extends Illuminate\Foundation\Testing\TestCase — add the trait there once. This keeps Laravel's HTTP/database assertions and this package's exception assertions composable in the same test method, which is the actual working pattern:
$this->assertThrows(ValidationException::class, fn () => $this->postJson('/register', ['email' => 'nope']));
$this->assertThrows(ValidationException::class, fn () => $this->postJson('/register', ['email' => '']));
$this->assertDoesNotThrow(fn () => $this->postJson('/register', ['email' => '[email protected]']));
Migration path. Do not big-bang rewrite the suite.
composer require --dev codeception/assert-throws and add the trait to the base TestCase.expectException() — that is not technical debt.CONTRIBUTING.md so reviewers apply one rule.Compatibility. Pin ^1.6 in require-dev and let the lockfile govern. The only real forcing function is a PHPUnit major upgrade; the project has kept pace through PHPUnit 13, and that is the thing to monitor. Two specific validation items during the pilot: (a) PHPUnit 10+ tightened trait/method signature behavior and removed docblock annotations — the 1.2.1 revert of assertThrows* signatures is evidence this is a live fault line, so confirm on our pinned PHPUnit; (b) confirm the trait does not collide with any assertThrows-named helper already present in our own base class or in third-party test helpers.
Sequencing.
Install → base TestCase → 3–5 pilot tests on API validation/auth → run a full CI cycle to confirm failure output renders as normal assertion failures (not raw exceptions) → convention doc → opportunistic conversion of one high-risk module → report back after one sprint. If the pilot doesn't visibly improve failure diagnostics (multiple reported failures per test instead of one-and-done), drop it; sunk cost is one day.
Maintenance. Minimal and bounded: a require-dev line, a lockfile entry, and a periodic bump. Because it is dev-only, it never appears in the production autoload path or container, so there is no runtime surface, no config to drift, and no ops runbook. The genuine maintenance exposure is one thing only — the test suite stops booting on a PHPUnit major until the dependency catches up. Mitigation: perform PHPUnit majors in a dedicated branch, verify codeception/assert-throws has a matching release first, and keep the fork-and-inline escape hatch documented (an in-house trait is ~30 lines of try/catch).
Support. Thin. Documentation is effectively the README plus the test suite; there is no supported edge-case contract for deeper matching (exception codes, getPrevious() chains, message regex, stack inspection). Budget for reading source rather than docs, and expect to write a thin internal wrapper for anything beyond class/message/code assertions — at which point owning the wrapper is the honest answer. Vendor responsiveness: releases exist and PHPUnit support has landed as point releases, but there is no SLA and no commercial backing. Treat published compatibility claims as unverified until checked against composer.json.
Scaling. Value scales with the number of error-path tests, not with team size or traffic — this changes nothing at runtime. Below a threshold of meaningful exception testing it is a dependency for its own sake. Conversely, in a Laravel codebase where exceptions are used as control flow (validation, authorization, model binding, HTTP exceptions — the common case), the payoff compounds quickly: “every endpoint rejects malformed input with the documented exception” becomes a realistic CI gate instead of a per-test tax, because one test method can cover N payloads and report all N failures.
Failure modes to plan for.
assertThrows helper in a base class or another test package produces confusing method-resolution errors — resolve before rollout, not after.assertThrows continues the test, potentially generating confusing downstream failures. Mitigate with the convention doc and by pairing each exception assertion with its assertDoesNotThrow happy-path twin.Ramp-up. A few hours, not a sprint. Developers need the assertion API (four methods), the convention for when to use it versus expectException(), and the Laravel exception taxonomy they will be asserting against. Do not schedule training; ship the convention doc plus 3–5 exemplary converted tests and let review propagate the pattern. Onboarding of new engineers is a single paragraph in CONTRIBUTING.md.
How can I help you explore Laravel packages today?