Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Clamav Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

  1. composer require sineflow/clamav:^2.1 (pin the minor; the 2.x line is still young).
  2. Make sure the daemon exists and is reachable before writing any PHP. In a container, that usually means a sidecar 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.
  3. Read, in this order: the README "Configuration" section, 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.
  4. Bind it once as a singleton in AppServiceProvider, wired to env-driven config:
$this->app->singleton(Scanner::class, fn () => new Scanner(
    // transport built from config('clamav.*')
));
  1. First use case: scan an uploaded file.
$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.

Implementation Patterns

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.

Gotchas and Tips

  • 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.
  • The stream pointer moves. After 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.
  • Non-seekable streams lie about size. Chunked S3/Guzzle streams cannot be rewound or 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.
  • Distinguish three outcomes, not two: clean / infected / error. isInfected() alone is not enough — log and surface the error path separately, and alarm on its rate.
  • Timeouts are your problem, not clamd's. Long socket timeouts block PHP-FPM workers. Set an explicit connect/read timeout in the transport config and keep it below your request timeout; move anything slower to the queue.
  • Configuration caching: if you publish 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.
  • Permissions, not config, break Unix sockets. The 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.
  • A stale virus database is a silent failure. Ship 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.
  • Log the signature name, not just "infected". getThreatName() in your logs turns "user says upload failed" into an actual triage signal.
  • Don't loop. Scanning per-chunk in an upload handler gives you per-chunk verdicts on fragments and destroys throughput. Scan once, on the assembled stream or file.
  • Extension points: the transport layer is the intended seam — swap in a custom transport for testing, instrumentation, or a remote scanning service. The result DTO is the other: wrap it in your own Verdict enum so a future v3 rename doesn't ripple through your codebase.
  • Upgrade note: v2.1.0 adds a method and nothing else — no deprecations, no signature changes. Bump at will; the only "new" risk is developers adopting scanStream() where scanFile() was already correct.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin