Watchdog's check system is fully plugin-based. Any PHP class that implements CheckInterface and carries the #[AutoconfigureTag('watchdog.check')] attribute is auto-discovered and immediately available in the UI β no registration or YAML configuration needed.
Every check implements these methods:
interface CheckInterface
{
// Unique type ID used in the database and API, e.g. "my_check"
public function getType(): string;
// Human-readable label shown in the check type selector
public function getLabel(): string;
// Default config values merged with the stored config before every run
public function getDefaultConfig(): array;
// Form field definitions for the UI (see Step 3)
public function getConfigSchema(): array;
// Where this check can run: AgentOnly, DashboardOnly, or Both
public function runnerMode(): RunnerMode;
// Execute the check and return an unpersisted CheckResult
public function run(SiteCheck $check): CheckResult;
// Column label for the monitored target in alert emails (or null)
public function getEmailTargetLabel(): ?string;
// Resolve the human-readable target value from config for alert emails
public function resolveEmailTarget(array $config): ?string;
}
Place your check in src/Check/:
<?php
declare(strict_types=1);
namespace App\Check;
use App\Entity\CheckResult;
use App\Entity\SiteCheck;
use App\Enum\CheckStatus;
use App\Enum\RunnerMode;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag('watchdog.check')]
final class MyCustomCheck implements CheckInterface
{
public function getType(): string
{
return 'my_custom'; // Must be unique across all checks; used in the database
}
public function getLabel(): string
{
return 'My Custom Check';
}
public function runnerMode(): RunnerMode
{
return RunnerMode::Both; // AgentOnly, DashboardOnly, or Both
}
public function getDefaultConfig(): array
{
return [
'threshold' => 100,
];
}
public function getConfigSchema(): array
{
return [
[
'name' => 'threshold',
'label' => 'Threshold',
'type' => 'number',
'required' => false,
'default' => 100,
'placeholder' => '100',
'help' => 'Fail if the measured value exceeds this threshold.',
],
];
}
public function getEmailTargetLabel(): ?string
{
return 'Threshold';
}
public function resolveEmailTarget(array $config): ?string
{
return isset($config['threshold']) ? (string) $config['threshold'] : null;
}
public function run(SiteCheck $check): CheckResult
{
$config = array_merge($this->getDefaultConfig(), $check->getConfig());
$result = new CheckResult();
$result->setCheck($check);
$threshold = (int) ($config['threshold'] ?? 100);
$value = $this->measureSomething();
if ($value > $threshold) {
$result->setStatus(CheckStatus::Fail);
$result->setMessage(sprintf('Value %d exceeds threshold %d', $value, $threshold));
} else {
$result->setStatus(CheckStatus::Ok);
$result->setMessage(sprintf('Value %d (threshold: %d)', $value, $threshold));
}
return $result;
}
private function measureSomething(): int
{
// Your measurement logic here
return 42;
}
}
That's the full implementation. The #[AutoconfigureTag] attribute combined with #[AutowireIterator] in CheckRegistry handles service wiring automatically β no YAML needed.
| Mode | When to use |
|---|---|
RunnerMode::DashboardOnly |
Requires a URL from the client record (HTTP, SSL). Cannot run on agents. |
RunnerMode::AgentOnly |
Requires direct host access β filesystem, /proc, process list. Cannot run on the dashboard worker. |
RunnerMode::Both |
Network-level check that works from any host: TCP, Redis, DNS, database connections. |
Each entry defines one form field in the UI:
[
'name' => 'my_field', // Config key (stored in check JSON config)
'label' => 'My Field', // Label shown in the form
'type' => 'text', // Field type (see table below)
'required' => true, // Validated client-side and server-side
'default' => '', // Pre-filled default value
'placeholder' => 'e.g. /var/log', // Input placeholder text
'help' => 'Explanation.', // Help text shown below the input
]
Available field types:
| Type | Renders as |
|---|---|
text |
Text input |
number |
Numeric input (integer) |
float |
Numeric input (decimal) |
password |
Password input (masked in UI) |
checkbox |
Boolean toggle |
duration |
Duration picker (stored as minutes, displayed as hours/days) |
client_url_select |
Dropdown of URLs configured for this client |
client_url_multiselect |
Multi-select of client URLs |
Return an empty array [] if your check needs no configuration.
public function run(SiteCheck $check): CheckResult
{
// Always merge defaults first β stored config may be partial
$config = array_merge($this->getDefaultConfig(), $check->getConfig());
$result = new CheckResult();
$result->setCheck($check);
// Set one of: Ok, Warn, Fail, Unknown
$result->setStatus(CheckStatus::Ok);
// Human-readable message shown in the dashboard and alert emails
$result->setMessage('Everything looks fine');
// Optional: set explicit response time in ms
// If omitted, the framework measures the run() duration automatically
$result->setResponseTimeMs(42);
return $result;
}
Status semantics:
| Status | Meaning | Triggers alerts? |
|---|---|---|
CheckStatus::Ok |
Check passed | Yes β on fail β ok transition (recovery) |
CheckStatus::Warn |
Approaching a threshold | No alert by default |
CheckStatus::Fail |
Check failed | Yes β on ok β fail transition |
CheckStatus::Unknown |
Check could not run (misconfiguration, unavailable resource) | No |
Use Unknown when the check cannot produce a meaningful result β missing configuration, unavailable file, unsupported platform. This does not trigger failure alerts.
Custom checks are standard Symfony services with full autowiring:
#[AutoconfigureTag('watchdog.check')]
final class MyCustomCheck implements CheckInterface
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly LoggerInterface $logger,
) {}
}
Add a test in tests/Unit/Check/:
final class MyCustomCheckTest extends TestCase
{
private MyCustomCheck $check;
protected function setUp(): void
{
$this->check = new MyCustomCheck();
}
public function testReturnsOkWhenBelowThreshold(): void
{
$siteCheck = new SiteCheck();
$siteCheck->setType('my_custom');
$siteCheck->setConfig(['threshold' => 100]);
$result = $this->check->run($siteCheck);
$this->assertSame(CheckStatus::Ok, $result->getStatus());
}
public function testReturnsFailWhenExceedingThreshold(): void
{
$siteCheck = new SiteCheck();
$siteCheck->setType('my_custom');
$siteCheck->setConfig(['threshold' => 0]);
$result = $this->check->run($siteCheck);
$this->assertSame(CheckStatus::Fail, $result->getStatus());
}
}
Mock infrastructure dependencies (HTTP clients, filesystem readers) via interfaces β all built-in checks follow this pattern with *Interface contracts for testability.
If your check type is generally useful, consider opening a pull request on GitHub. See CONTRIBUTING.md for the development workflow and code style requirements.