adriengras / guard-php
A tiny guard() helper for PHP (Symfony/Laravel/plain).
Requires
- php: ^8.2
Requires (Dev)
- pestphp/pest: ^3
- phpstan/phpstan: ^2
- phpunit/phpunit: ^11.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A tiny PHP library bringing the guard concept (inspired by Kotlin, Swift, Dart…) to PHP.
Available globally via Composer — works in plain PHP, Symfony, and Laravel.
✨ Features
- Global
guard()function available everywhere - Throws an exception if a condition is not met
- Supports:
- Simple error message
- Existing exception instance
- Lazy exception factory (
callable)
- Caller blame mode: the error is shown as if it occurred where
guard()was called, not inside the package - PHP 8.2+ compatible (tested on 8.2, 8.3, 8.4 and 8.5)
- Zero runtime dependencies
❓ Why this package?
Defensive programming is a key practice to make your code more reliable and easier to debug.
Languages like Kotlin and Swift provide a native guard statement to quickly validate assumptions and fail fast when something is wrong.
In PHP, similar checks often look like this:
if ($price < 0) { throw new InvalidArgumentException('Price must be non-negative'); }
This package brings a concise, expressive, and consistent way to write those checks:
guard($price >= 0, 'Price must be non-negative');
Benefits:
- Readability – One-line, expressive intent
- Consistency – Same syntax across all projects and frameworks
- Less boilerplate – No repetitive if + throw blocks
- Framework-agnostic – Works in plain PHP, Symfony, Laravel…
⚙️ How it works — Caller blame mode
By default, guard() is in caller blame mode ($blameCaller = true):
- It scans the debug backtrace to find the first frame outside
vendor/and outside theguard.phpfile itself. - It then tries to rewrite the
$fileand$lineproperties of the exception viaReflectionPropertyso the error appears to originate from your code. - If PHP forbids modifying these properties (some runtimes mark them internally as readonly),
guard()falls back to wrapping the original exception in anErrorException:- The new exception has
fileandlineset to the caller’s location - The original exception is preserved as
$previous
- The new exception has
⚠️ The fallback changes the exception type. If that happens, a
catch (DomainException $e)around yourguard()call will not match — anErrorExceptioncomes out instead. When the exact type matters more than the reported location, disable caller blame:guard($condition, $exception, null, null, false).
Example — without blame:
In vendor/your-vendor/guard-php/src/functions.php line 47: Price must be non-negative
Example — with blame (default):
In src/Service/CheckoutService.php line 123: Price must be non-negative
You can disable caller blame explicitly:
guard($condition, 'message', null, null, false);
📦 Installation
composer require adriengras/guard-php
The global function is automatically loaded via autoload.files — no extra configuration needed.
🚀 Usage
Basic example
guard($price >= 0, 'Price must be non-negative');
With a custom exception
guard($user !== null, new DomainException('User not found'));
Lazy exception (callable)
guard($stock >= $qty, fn() => new OutOfRangeException("Not enough stock for SKU {$sku}"));
The callable is only invoked when the condition fails, and it must return a Throwable —
otherwise guard() throws an ErrorException blaming your call site.
A string is always a message
A string is never treated as a callable, even when it matches an existing function name:
guard($items !== [], 'count'); // InvalidArgumentException: count
To pass a factory, use a closure or an invokable object — not a function name.
Code and previous exception
$code and $previous only apply to the message form. A Throwable you supply yourself, or one
returned by a callable, is used untouched:
guard($ok, 'Operation failed', 400, $previousException); // code 400, previous set guard($ok, new DomainException('Nope'), 400, $previous); // 400 and $previous are IGNORED
🧩 Framework compatibility
- Symfony – works out-of-the-box in controllers, services, commands…
- Laravel – works directly in controllers, jobs, events…
- Plain PHP – just require 'vendor/autoload.php';
🧪 Tests
Tests are written with Pest.
Run tests:
composer test
Run tests with coverage:
XDEBUG_MODE=coverage composer test:cov
Static analysis
PHPStan runs at level max over src/ and tests/:
composer stan
Run both before pushing:
composer check
🤝 Contributing
Contributions are welcome!
If you’d like to contribute:
- Fork the repository
- Create a new branch (
git checkout -b feature/my-feature) - Make your changes
- Run the tests and static analysis (
composer check) - Submit a Pull Request
📄 License
MIT - You can find the licence in the LICENSE file.