How to monitor your Laravel app for critical vulnerabilities using Oh Dear
Published on July 18, 2025 by Mattias Geniar
A few months ago, a critical RCE vulnerability landed in Livewire v3 (CVE-2025-54068). Stephen Rees-Carter walked through the impact on Securing Laravel: unauthenticated attackers could trigger remote code execution on apps that mounted a vulnerable component in a particular way. The patch shipped quickly. The problem, as always, was the gap between the patch shipping and you actually deploying it.
That gap is where production apps get owned. You don't get owned because the maintainers were slow. You get owned because nobody on your team noticed the advisory, or someone merged the bump but the deploy silently failed, or the dependency is pinned three layers deep and you didn't realise you were on a vulnerable version.
This post is the practical version of Freek's writeup: a complete walkthrough of wiring spatie/laravel-health into your app, exposing a health endpoint, and pointing Oh Dear at it so you actually find out when one of your dependencies turns into a problem.
The setup, in one sentence #
spatie/laravel-health runs checks inside your app, including a check that compares your installed packages against the FriendsOfPHP security advisories database. Oh Dear pulls the result on a schedule and alerts you the moment something goes red. That's it. The rest is plumbing.
Install the packages #
composer require spatie/laravel-health composer require spatie/security-advisories-health-check
The first package is the framework. The second is the addon that does the actual vulnerability scan. It uses the same data source as composer audit, so if composer audit would flag a package, this check will too. Difference is, this one runs in production on a schedule, not just on your laptop when you remember.
Publish the config:
php artisan vendor:publish --tag="health-config"
Register your checks #
Create a service provider that registers the checks you care about. Security advisories first, the rest because if you've already gone to the trouble of wiring up health checks, you might as well catch a few other things while you're here.
namespace App\Providers; use Illuminate\Support\ServiceProvider; use Spatie\Health\Checks\Checks\DatabaseCheck; use Spatie\Health\Checks\Checks\DebugModeCheck; use Spatie\Health\Checks\Checks\EnvironmentCheck; use Spatie\Health\Checks\Checks\HorizonCheck; use Spatie\Health\Checks\Checks\OptimizedAppCheck; use Spatie\Health\Checks\Checks\UsedDiskSpaceCheck; use Spatie\Health\Facades\Health; use Spatie\SecurityAdvisoriesHealthCheck\SecurityAdvisoriesCheck; class HealthCheckServiceProvider extends ServiceProvider { public function boot(): void { Health::checks([ SecurityAdvisoriesCheck::new(), UsedDiskSpaceCheck::new(), DebugModeCheck::new(), EnvironmentCheck::new(), DatabaseCheck::new(), HorizonCheck::new(), OptimizedAppCheck::new(), ]); } }
Register it in bootstrap/app.php inside withProviders():
->withProviders([ App\Providers\HealthCheckServiceProvider::class, ])
At this point, php artisan health:check already works locally. Run it. You'll see a table per check with a status. If you're on a vulnerable Livewire (or anything else FriendsOfPHP knows about), the security check will be red.
Run the checks on a schedule #
Health checks that don't run aren't health checks, they're decorative code. Schedule the runner so results are always fresh by the time Oh Dear comes looking:
use Spatie\Health\Commands\RunHealthChecksCommand; protected function schedule(Schedule $schedule): void { $schedule->command(RunHealthChecksCommand::class)->everyMinute(); }
Every minute is fine. The checks are cheap and the security advisory dataset is cached locally.
Expose the endpoint Oh Dear will read #
In config/health.php, enable the Oh Dear endpoint:
'oh_dear_endpoint' => [ 'enabled' => true, 'always_send_fresh_results' => true, 'secret' => env('OH_DEAR_HEALTH_CHECK_SECRET'), 'url' => '/oh-dear-health-check-results', ],
Generate a random secret and add it to .env:
OH_DEAR_HEALTH_CHECK_SECRET=replace-with-a-long-random-string
spatie/laravel-health registers the route for you. Oh Dear hits it, sends the secret, and gets back a JSON document describing the state of every check. That JSON looks roughly like this when something has gone wrong:
{ "finishedAt": 1738879833, "checkResults": [ { "name": "SecurityAdvisories", "label": "PHP Package Security", "status": "failed", "notificationMessage": "Found 1 security vulnerability in livewire/livewire", "shortSummary": "1 vulnerability", "meta": { "vulnerabilities": [ { "package": "livewire/livewire", "version": "3.6.2", "advisories": ["CVE-2025-54068"] } ] } } ] }
Anything with "status": "failed" becomes a notification.
Point Oh Dear at the endpoint #
In Oh Dear, open your site, go to Application health, and add the URL:
https://your-app.com/oh-dear-health-check-results
Paste the same secret you put in .env. Hit save. Oh Dear will start polling, and the next time RunHealthChecksCommand writes a failed result for the security advisories check, you'll get an alert through whatever channel you've configured: email, Slack, Discord, Teams, webhook, your phone buzzing at 2am, your call.
If you've never set up a notification destination, the docs walk through it. Slack is the path of least resistance if you don't already have one.
Why this beats Dependabot alone #
Dependabot is great. We use it on this codebase. But Dependabot scans the repository. Laravel Health scans what's actually running on your servers. Those are not always the same thing:
- A security PR was merged but the deploy failed and nobody noticed.
- The fix is on
mainbut production is pinned to a tag. - You forgot you had an old staging server still running an unpatched version.
- A vendored copy of a library is masking the real version Composer thinks is installed.
Dependabot catches the first category. Laravel Health plus Oh Dear catches the deployed state. Run both. They cost roughly nothing and they answer different questions.
Bonus: catch it earlier with composer audit in CI #
Health monitoring tells you about production. CI tells you before a vulnerable dependency ever reaches main. Add composer audit to your GitHub Actions workflow:
name: Security Audit on: push: pull_request: schedule: - cron: '0 */6 * * *' jobs: security: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: '8.4' - run: composer install --no-progress - run: composer audit
The scheduled cron is the important bit. New advisories get published against existing versions all the time. A push trigger only runs when you commit. The cron run gives you a daily(ish) sanity check against the lockfile that's already on main.
What you've got now #
After 15 minutes of work, you have:
- A health check running every minute on every server.
- A JSON endpoint Oh Dear can read.
- Notifications hitting your inbox or Slack the moment a known CVE shows up against any package in your
composer.lock. - CI failing PRs that try to introduce a vulnerable dependency.
- A scheduled CI run catching newly disclosed CVEs against your existing lockfile.
The Livewire RCE was the prompt for this post, but the setup pays off for every future advisory you don't have to find out about on Twitter at midnight. The packages are free. The CI minutes are free. Oh Dear's Application Health is included in every plan. Set it up once and forget about it until it saves you.
If you want to read more on Laravel security in general, Stephen Rees-Carter's Securing Laravel is the best newsletter on the subject. Subscribe to that, set up the health check, and sleep slightly better.