sineflow/clamav
Laravel integration for ClamAV virus scanning. Scan uploaded files and local paths through clamd using a simple API or validation rule, with configurable Unix socket or TCP connections for reliable malware detection.
sineflow/clamav is a thin PHP client for clamd — it does not bundle ClamAV, and it does not scan anything itself. Version 2.1.0 is an additive minor release (Scanner::scanStream()), so it drops into an existing v2.x install with no code changes.
composer require sineflow/clamav:^2.1 (pin the minor; the 2.x line is still young).clamav/clamav image exposed on TCP 3310, or a shared volume holding /var/run/clamav/clamd.ctl. Verify with clamdcheck / nc -U /var/run/clamav/clamd.ctl before blaming the package.src/Scanner.php (the whole public API fits on one screen), then the transport classes (TCP socket, Unix socket, executable/CLI). Those three transports have different capabilities — read them first or you will spend an afternoon debugging an INSTREAM failure.AppServiceProvider, wired to env-driven config:$this->app->singleton(Scanner::class, fn () => new Scanner(
// transport built from config('clamav.*')
));
$result = app(Scanner::class)->scanFile($request->file('avatar')->getRealPath());
if ($result->isInfected()) {
Log::warning('Malware upload', ['sig' => $result->getThreatName(), 'user' => $request->user()?->id]);
abort(422, 'File rejected.');
}
UploadedFile is already written to a temp path, so scanFile() is the right call here — do not reach for scanStream() just because it is new.
Wrap the scanner in one app-level service. Never call Scanner from controllers directly. A MalwareScanner class gives you a single fail-closed policy, one place to log, and one place to fake in tests.
Three scan entry points, three jobs:
| Source | Method | Notes |
|---|---|---|
UploadedFile, local disk |
scanFile() |
Fastest; clamd reads from disk. |
| S3 / remote disk / base64 / webhook payload | scanStream() (new) |
Stream straight from Storage::readStream() — no temp file, no S3 round trip to local disk. |
| Raw request body | scanStream(fopen('php://input', 'rb')) |
Useful for signed webhooks carrying attachments. |
Validation-first, scan-second. Keep MIME/size/extension rules in a FormRequest so obviously bad input never reaches clamd, then scan as a second gate. A custom Rule object is the cleanest composition point:
public function passes($attribute, $value): bool
{
return ! app(Scanner::class)->scanFile($value->getRealPath())->isInfected();
}
Push big files to a queue. Scanning is a blocking socket round trip (seconds for large/compressed files). Store the upload in a quarantine disk, dispatch ScanUploadedFile, and only move to public on a clean verdict. For inline payloads (avatars, small documents) keep it synchronous — a queue hop costs more than the scan.
Fail closed, always. The single most common production bug with any AV client is treating an exception as "probably fine":
try {
$result = $scanner->scanFile($path);
} catch (Throwable $e) {
report($e);
return Verdict::REJECTED; // not ACCEPTED
}
Health check. Add an artisan command / /health probe that scans a tiny known-clean stream. A silently dead clamd is worse than no AV, because you believe you are protected.
Test with EICAR bytes. scanStream() makes this trivial and fixture-free:
$eicar = 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*';
$stream = fopen('php://memory', 'r+');
fwrite($stream, $eicar);
rewind($stream);
$this->assertTrue($scanner->scanStream($stream)->isInfected());
Keep that test out of CI unless a real clamd is available — instead fake your MalwareScanner wrapper for feature tests and run the EICAR test in an integration suite.
scanStream() is daemon-only. INSTREAM is a clamd protocol command; the executable/CLI transport shells out to clamscan/clamdscan and generally cannot stream arbitrary PHP resources. If you switch transports in config, scanStream() may stop working. Guard with a capability check or simply always run the daemon in production.StreamMaxLength is a hard ceiling. clamd.conf defaults to ~25 MB (lower on some builds). Exceeding it returns a size limit error, not a "clean" result — if you catch-and-allow, oversized files bypass scanning entirely. Check your limit against Laravel's upload_max_filesize/post_max_size.scanStream(), the resource is at EOF. rewind() (or $stream->rewind()) before persisting it, or you will store zero-byte files and spend hours on it.fstat'd reliably. Buffer to php://temp first if the transport needs a known length. Same applies to php://input, which is readable exactly once.isInfected() alone is not enough — log and surface the error path separately, and alarm on its rate.config/clamav.php, php artisan config:cache in deploy will freeze env values. Keep the socket/host values in env and re-cache on release, not at runtime.www-data/php-fpm user needs read-write on clamd.ctl and traverse rights on its directory. In Docker, the socket path inside the app container must match the volume mount exactly.freshclam as a scheduled container/cron; expose the DB age in your health check. A signature from last month will happily pass today's malware.getThreatName() in your logs turns "user says upload failed" into an actual triage signal.Verdict enum so a future v3 rename doesn't ripple through your codebase.scanStream() where scanFile() was already correct.How can I help you explore Laravel packages today?