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.
Install (dev-only, never in the production autoload path):
composer require --dev codeception/assert-throws
Pin to the 1.x line that matches your PHPUnit major — the release history is a compatibility ladder (1.3.0 → PHPUnit 10, 1.4.0 → 11, 1.5.0 → 12, 1.6.0 → 13). Check vendor/codeception/assert-throws/composer.json for the actual phpunit/phpunit constraint rather than trusting the repo description, which still claims "PHPUnit 6+" even though support was dropped in 1.1.0.
Wire it once, in the base test case. This is the whole integration — there is no service provider, config, or facade:
namespace Tests;
use Codeception\AssertThrows\AssertThrowsTrait;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
use AssertThrowsTrait;
}
Every unit, feature, and (if used) Codeception-style test now has assertThrows(), assertThrowsWithMessage(), assertDoesNotThrow(), and assertDoesNotThrowWithMessage().
Read the source first, not the README. The package is a few dozen lines in vendor/codeception/assert-throws/src/AssertThrowsTrait.php plus a test file. Five minutes there answers every question the docs don't: exact argument order, whether messages are matched by substring or equality, and what it catches. Do this before writing the first test — see the signature gotcha below.
First use case. A form-request validation test where you want three bad payloads in one method instead of three near-identical test methods:
public function test_registration_rejects_malformed_payloads(): void
{
$this->withoutExceptionHandling();
foreach ([['email' => 'nope'], ['email' => ''], ['email' => null]] as $payload) {
$this->assertThrows(
ValidationException::class,
fn () => $this->postJson('/api/register', $payload),
'payload: ' . json_encode($payload)
);
}
}
That is the entire value proposition in one method: three error paths, three named failures, test keeps running.
Multi-case error-path blocks. The core day-to-day pattern. Loop a table of invalid inputs, assert the same exception class for each, and pass a distinct $message so a failure names the offending payload instead of pointing at a line number inside a loop.
Authorization and policy denials. Asserting the exception class is stronger than asserting a 403 status, because it distinguishes "policy denied it" from "some middleware returned 403 for an unrelated reason":
$this->actingAs($guest);
$this->assertThrows(AuthorizationException::class, fn () => $this->putJson("/api/posts/{$post->id}", $data));
Same shape for route-model-binding misses (ModelNotFoundException) and HttpResponseException aborts.
Domain invariants in actions and services. Where the intent is "this rule is enforced in the domain, not just in the form request," call the class directly and assert:
$this->assertThrows(InsufficientFunds::class, fn () => $account->withdraw(500));
$this->assertThrows(InsufficientFunds::class, fn () => $account->withdraw(0));
$this->assertDoesNotThrow(fn () => $account->withdraw(50));
assertDoesNotThrow as a regression guard. The gap in most suites: proving a valid input actually doesn't blow up. It is a cheap way to catch an over-eager guard clause added later, and it pairs naturally with the negative cases in the same test so the reader sees both branches.
Mixed valid/invalid batches. For queue jobs, importers, or bulk endpoints, iterate items and assert per item without aborting the test:
foreach ($items as $item) {
$valid
? $this->assertDoesNotThrow(fn () => $job->handle($item), get_class($item))
: $this->assertThrows(InvalidItem::class, fn () => $job->handle($item), get_class($item));
}
Build a vocabulary on top of it. The package is a thin primitive. Wrap the three or four exceptions your app actually throws in named helpers on the base TestCase — assertValidationFails(array $payload), assertForbidden(callable $fn), assertNotFound(callable $fn). Reviewers then read intent, not class strings, and if you ever fork the package only one file changes.
Use it at the layer where the exception actually propagates. In Laravel HTTP tests the exception handler converts most exceptions into responses, so assertThrows around $this->postJson(...) only works with withoutExceptionHandling() in effect (call it in setUp() for a dedicated test class if most of your tests there are error-path tests). Otherwise drop down to the service, action, or Validator::make() and assert there.
Keep expectException() for single-path tests. A test that asserts exactly one exception and then ends is not technical debt; native is fine and clearer. Write the split rule into CONTRIBUTING.md so reviewers have one mental model: one error path → native; multiple error paths or assertions after the throw → assertThrows.
Watch the signature across 1.x. Release 1.2.1 explicitly reverted a method signature change for 1.x versions, so argument order for assertThrows* may differ between minors. Read the trait in vendor/ after every composer update that bumps this package, and don't copy snippets from blog posts. If a call silently "passes" when it shouldn't, this is the first thing to check.
\Error is not \Exception. If the trait catches \Exception, then TypeError, DivisionByZeroError, and ArgumentCountError escape the assertion and halt the test — the exact behavior you installed the package to avoid. Verify the catch clause in the source, and don't use assertThrows for \Error subclasses unless you've confirmed \Throwable is caught.
Non-halting semantics can cascade. After a failed assertThrows, execution continues into code that assumed the throw happened — a response that was never returned, a model that was never created, a null that then produces a second, misleading failure. Keep the assertion block self-contained and don't chain dependent assertions after a case you expect to fail. If you see two failures where you expected one, this is why.
Laravel's exception handler hides everything. The single most common "why is this not throwing?" moment. $this->get(), postJson(), and friends return a TestResponse for 404/403/422 rather than throwing. Enable withoutExceptionHandling() or assert at the domain layer. Note that in withoutExceptionHandling() mode a real failure surfaces as a raw exception mid-test rather than a clean assertion failure, so read the trace.
Message matching is not what you think it is. assertThrowsWithMessage follows PHPUnit's message semantics, which are typically substring matching — 'invalid' will match 'The email is invalid.'. Over-broad substrings make assertions pass for the wrong reason. Prefer a distinctive fragment, and remember that the message is often the localized/translated one if you're asserting through a request.
Codes and getPrevious() chains need a wrapper. If you need to assert a specific exception code, a previous exception in the chain, or a regex on the message, the package is deliberately too thin. Write assertThrowsCode(int $code, callable $fn) yourself in the base TestCase — that's the honest split, and it's five lines.
Name collisions. If any other test helper or your own base class already defines assertThrows, PHP fatals on the duplicate trait method. Grep before you add the trait; also check for conflicting traits in packages that ship test utilities.
On Pest, skip this. ->throws() covers the primary use case idiomatically, and layering a third-party trait on top is friction with no payoff. Adopt only in PHPUnit- or Codeception-based suites.
Pin and monitor, don't audit. The only forcing function is a PHPUnit major upgrade; the release history shows the maintainer shipping support for 10, 11, 12, and 13 as point releases. That is the thing you're paying for — nobody owns fixing a hand-rolled try/catch trait at each upgrade. Set a calendar reminder when you bump PHPUnit, and treat the 19-star/0-dependent status as "cheap and forkable," not "strategic dependency." Exit cost is deleting one require-dev line and one use statement.
Failure diagnostics are the success metric. Before adopting, count expectException and raw try/catch blocks in your suite and how many tests assert exactly one exception. After a sprint of piloting on auth and validation, look at whether failures now name the specific invalid input. If they don't, drop it — the sunk cost is one day.
How can I help you explore Laravel packages today?