mainlycode/health-bundle
Health Bundle adds configurable health-check endpoints to PHP applications, letting monitoring tools verify application, database, cache, and dependency status through simple, extensible checks.
This is a Symfony bundle, not a Laravel package. Before anything else, decide which of two jobs you're doing:
/health endpoint → install it directly.GET /health route → read src/ for the check-collection pattern and port it (see Implementation Patterns for the mapping).The package has 0 stars, 0 dependents, and a single maintainer. That doesn't make it bad — a health endpoint is ~200 lines of glue — but it does mean you should read the source, not just the README, and be prepared to fork (MIT, so trivial).
Minimal install (Symfony):
composer require mainlycode/health-bundle
If Flex doesn't auto-register it, add to config/bundles.php:
MainlyCode\HealthBundle\MainlyCodeHealthBundle::class => ['all' => true],
Then verify what actually got wired up — this is your first debugging step and answers 80% of questions:
bin/console debug:router | grep -i health
bin/console debug:container --tag=<the check tag you find in src/>
curl -i http://localhost:8000/health
Where to look first, in order:
| Path | Why |
|---|---|
src/Resources/config/ (or config/) |
The service definitions — this tells you the real tag name, route path, and default status codes |
src/DependencyInjection/ |
The compiler pass; how checks are collected and what config keys exist |
src/Controller/ or src/Health/ |
The endpoint + the check interface you'll implement |
README.md |
Often lags the code in small bundles — treat it as a hint |
First use case: don't build a custom check yet. Get the bundled endpoint responding 200, then add one check for the dependency that actually pages you — usually the database. A health endpoint that reports on twelve things is noise; one that reports on the database and the queue is signal.
The bundle almost certainly revolves around a check interface plus a DI tag. The shape you should expect:
namespace App\Health;
use MainlyCode\HealthBundle\Health\HealthCheckInterface; // confirm FQCN in src/
final class DatabaseCheck implements HealthCheckInterface
{
public function __construct(private \PDO $pdo) {}
public function check(): bool // or a Result/HealthStatus object
{
return (bool) $this->pdo->query('SELECT 1');
}
}
Autoconfigure it so you never hand-write tag names in services.yaml:
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\Health\:
resource: '../src/Health/'
If the bundle declares autoconfigure on its interface, that's enough. If not, add _instanceof with the exact tag from Resources/config:
_instanceof:
MainlyCode\HealthBundle\Health\HealthCheckInterface:
tags: ['mainlycode.health.check']
Use autoconfigure + _instanceof in preference to per-service tags: tag typos fail silently — a check that's never collected just disappears from the response and you'll debug the checker instead of the wiring.
Most bundles of this size expose one route. If you're on Kubernetes or behind an ALB, one route is a trap: a database blip will fail the endpoint, the orchestrator will conclude the process is broken, and restart every replica — turning a slow query into a full outage.
Two viable approaches with a single-route bundle:
/health?deep=1 for readiness, plain /health returns {"status":"ok"} unconditionally. Cheap on the kubelet's 10-second probe budget./live controller that returns 200 always, plus the bundle's /health reserved for readiness and your load balancer.Prefer the second. Liveness should never touch the database.
Whatever the bundle returns by default, pin it down and make it a contract:
{ "status": "ok", "checks": { "database": "ok", "redis": "ok" }, "duration_ms": 4 }
200 on healthy, 503 on degraded — orchestrators and ALBs read the status code, humans read the body.{"status":"fail"}.HEAD should work. Some probes send it.security.firewalls.main.security: false via access_control, or a #[IsGranted]-free path. A 302 to a login page will be read as a failure.ATTR_TIMEOUT, Redis OPT_TIMEOUT) so a hung dependency fails in ~1s rather than the kubelet's timeout, which gives you an unhelpful "connection reset".MonologBundle's handlers or your log volume quietly doubles.curl -fsS http://localhost:8000/health || (docker compose logs; exit 1) after bringing up the stack catches wiring regressions before deploy.The concepts map almost 1:1:
| Symfony bundle | Laravel equivalent |
|---|---|
| Bundle + Flex recipe | Package + ServiceProvider |
| Service tag + compiler pass | $this->app->tag([...], 'health') + $this->app->tagged('health') |
Resources/config/*.yaml |
config/health.php + mergeConfigFrom |
| Route in bundle config | routes/api.php or bootstrap/app.php |
| Check interface | interface HealthCheck { public function check(): bool; } |
200/503 |
response()->json([...], $ok ? 200 : 503) |
Laravel 11+ already ships a trivial /up route (->withRouting(health: '/up') in bootstrap/app.php) — that's your liveness probe. For readiness, either port this bundle's tagged-check pattern into a small HealthController or use spatie/laravel-health, which is the mature ecosystem answer and is worth preferring over hand-rolling unless you specifically want zero dependencies.
The port is small enough that copying the idea is usually better than shimming a Symfony bundle into Laravel — they don't compose.
"mainlycode/health-bundle": "1.0.*"), commit the lockfile, and assume you may own the code. Because it's MIT, vendor it into src/ or fork it the moment you need a change you can't upstream — the whole thing is a controller, an interface, and a compiler pass.grep -r "interface .*Check" vendor/mainlycode/health-bundle/src/ takes ten seconds and saves an afternoon.200. Verify collection explicitly: bin/console debug:container --tag=<tag> should list your check. If it's empty, the problem is DI configuration, not your check logic./health collides more often than you'd think — existing monitoring routes, API gateways, and reverse proxies all claim it. Check debug:router for a duplicate and confirm route ordering; a controller route defined in your app will usually win over one imported from a bundle, which is a silent way to keep returning a hardcoded 200.SQLSTATE[08006] ... host=db-prod-1 is a gift to anyone scanning. Return a check name and a status, log the exception server-side. Also confirm whether the endpoint is publicly reachable from the internet — it usually shouldn't be.failureThreshold: 3. If a check can take longer than ~5s under load, set timeoutSeconds and failureThreshold deliberately, or you'll get restart loops on the first slow query.kernel.debug may change behavior — some bundles return verbose output only in debug. Test the endpoint with APP_ENV=prod at least once, or you'll ship a response shape you never actually validated.#[AsDecorator] on the controller service) so upgrades stay clean; (4) the config tree for path/status codes if it exposes them.KernelBrowser hitting /health, plus a check stub swapped in via the test container that returns false, asserting 503. That test is the difference between trusting the endpoint in production and hoping.How can I help you explore Laravel packages today?