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

Filament Antivirus Laravel Package

johnrivera7/filament-antivirus

Filament Antivirus adds antivirus scanning to Filament file uploads, helping validate files before acceptance and block infected uploads. It provides a security layer for forms and resources, making Laravel admin panels safer from malicious files.

View on GitHub
Deep Wiki
Context7

Getting Started

Prerequisites, in order of what actually blocks you. A running clamd (sidecar container, systemd service, or TCP endpoint) is the hard dependency — the composer install is the easy part. Confirm clamdscan --version or echo PING | nc -U /var/run/clamav/clamd.ctl works from the app server before touching the package. Second, confirm your Filament major. Filament's temporary-upload internals change between majors, and this package has zero dependents — you are the integration test.

v1.0.0 is the initial release. That's good news (the feature list is now concrete rather than aspirational) and a caution (no upgrade history, no production track record). Pin it exactly.

composer require johnrivera7/filament-antivirus:^1.0
php artisan vendor:publish --tag="filament-antivirus-config"
php artisan migrate   # creates antivirus_incidents

Then register the plugin in your panel provider:

// app/Providers/Filament/AdminPanelProvider.php
public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->plugin(\Johnrivera7\FilamentAntivirus\FilamentAntivirusPlugin::make());
}

Where to look first, in this order:

  1. config/filament-antivirus.php — the whole behaviour surface: driver (clamdscan / clamscan / null), socket/host/port, timeout, quarantine disk and path, and what happens on scanner error. Read it before the README.
  2. AntivirusFileRule — the release notes name this explicitly, and it resolves the biggest open question from the previous assessment: scanning happens at validation time, not at the Livewire temp-upload stage. That single fact defines your blast radius (see Gotchas). Read the rule class itself to see whether it's a ValidationRule invoked synchronously or something queued.
  3. The service provider — the container binding for the scanner driver. That binding is your seam for swapping implementations, and it's what the built-in null driver swaps into.
  4. FilamentAntivirusDashboard page class — check whether it's a Resource, a Page, or a Widget, and whether canAccess() is overridable, because that dictates how you authorize access to a directory of filenames.
  5. The antivirus:scan command signature — --dry-run, path arguments, chunking, driver override. This is your backfill tool; know its flags before you point it at storage/.

First use case — the 20-minute smoke test. Take one existing FileUpload field, attach ->rule(new AntivirusFileRule()), and submit the EICAR test string:

echo 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > eicar.txt

Upload it. You want a validation error on the field, not a 500. Then run php artisan antivirus:status — it should report driver reachability and signature-database freshness; if it doesn't tell you the signature age, that's a gap you'll want to monitor yourself.

Once that works, the second smoke test is the interesting one: stop clamd and upload again. The behaviour you observe here is a product decision you need to write down and defend, not a quirk to shrug at.

Implementation Patterns

Per-field opt-in is the right default, and you should be deliberate about which fields get it. Attaching AntivirusFileRule to every upload in the panel means a 200 MB video upload holds a Livewire request open for 30+ seconds and then times out. Enable it on fields that accept documents, archives, office files, and anything user-supplied; skip media you generate yourself, images you re-encode on ingest, and fields backed by trusted internal sources.

use Johnrivera7\FilamentAntivirus\Rules\AntivirusFileRule;

FileUpload::make('attachment')
    ->disk('private')
    ->acceptedFileTypes(['application/pdf', 'application/zip'])
    ->maxSize(25 * 1024)
    ->rule(new AntivirusFileRule())   // verify constructor args in source
    ->required(),

Use the null driver in local and testing — don't build your own fake first. The release ships a null driver and ES/EN translations, which means the package is designed to be inert when you want it inert. Point the config at the null driver per-environment rather than commenting out rules, so your form definitions stay identical across environments and you're not testing a different code path than you ship.

Wrap it before you wire it. Given the maturity profile (single maintainer, initial 1.0, no dependents), bind the package behind your own contract on day one. This costs an hour and buys you the ability to swap to a cloud scanner or a hand-rolled clamdscan call without touching a single form.

// app/Contracts/FileScanner.php
interface FileScanner
{
    public function scan(string $path): ScanResult;
    public function quarantine(string $path, array $context = []): void;
}

Keep a LogOnlyScanner for shadow mode and delegate to the package's scanner binding everywhere else.

Shadow mode before enforcement. This is the pattern that saves you from the false-positive incident. Run the scan enabled but non-blocking — log the detection, let the file through — for two weeks on your highest-traffic upload path. You'll learn two things you can't learn any other way: your real false-positive rate, and whether clamd is actually up (a daemon that's been silently dead for a week looks identical to a week with no detections). Check whether AntivirusFileRule supports a non-blocking mode natively; if not, implement shadow mode in your wrapper and log to the same place incidents go.

Treat the dashboard as an operations tool, not a dev tool. Whoever works the quarantine queue needs: original filename, uploader, tenant, timestamp, detection signature, and a release/restore action. Verify the dashboard captures all of these before you rely on it — the antivirus_incidents table gives you the join points, so adding columns is cheap, but missing an uploader or tenant column means the queue is unworkable in a multi-tenant panel. Gate the page behind a policy or canAccess(); a quarantine list is a directory of filenames containing potentially-malicious and potentially-PII content and should not be visible to every panel user.

Run antivirus:scan as your backfill, not a bespoke command. The previous gap — "everything already in storage/ is unscanned" — now has a first-party answer. Point it at existing disks, run it on a queue worker with low concurrency so it doesn't starve the interactive path, and quarantine findings for human review. Do not auto-delete. Do a dry run against a sample directory first and time it, so you know what the full corpus costs.

Local development needs a clamd, and Docker Compose is the cheapest route. Without it, every developer either runs the null driver (and never tests the reject path) or hits confusing errors.

# docker-compose.yml
services:
  clamav:
    image: clamav/clamav:stable
    ports: ['3310:3310']
    volumes: ['clamav-db:/var/lib/clamav']
volumes:
  clamav-db:

Point the config at tcp://clamav:3310 in Docker and unix:///var/run/clamav/clamd.ctl on the host. First boot takes a while — freshclam downloads the full signature set.

Gotchas and Tips

AntivirusFileRule runs at validation time — which means the temp file already exists on disk. This is now confirmed, not inferred, and it matters: Livewire has already written the upload to its temporary disk before your rule fires. So "rejected" and "never landed" are different things. Confirm where the temp file lives, whether validation failure cleans it up, and whether the quarantine manager moves the file or copies it. If a rejected file lingers in livewire-tmp until the prune job runs, that's your exposure window — shorten it or add a cleanup hook.

Know exactly which upload paths you're covering. The rule covers Filament form fields. It almost certainly does not cover plain Laravel $request->file() endpoints, API uploads, CSV imports, queued jobs fetching remote files, or Storage::put() calls elsewhere in your codebase. Read the source, write down the covered surfaces, and route everything else through your FileScanner interface manually. Overstating coverage is how you get an uncomfortable conversation after an incident.

Fail-open vs fail-closed is a product decision, and the default may be the wrong one for you. If clamd is unreachable and the package fails open, you have a decoration, not a control — silently. For public anonymous uploads, fail closed. For an internal HR portal where blocking a legitimate hire packet for an hour is a business problem, maybe fail open with alerting. Make it explicit in config, document it, and alert on daemon unreachability. antivirus:status is your hook for a healthcheck endpoint or scheduled monitor — wire it into something that pages a human.

Scanning in the request path will time out on large files. ClamAV is CPU- and I/O-heavy; a 100 MB PDF can hold a Livewire request open long enough to hit max_execution_time. Mitigate three ways: cap ->maxSize() on scanned fields (10–25 MB covers most document intake), set the package timeout below your PHP limit, and — if you genuinely need large-file scanning — build an async path that accepts the file, queues the scan, and shows a "pending review" state. Never let an unscanned file be visible to other users while pending.

Remote disks (S3/Spaces) need streaming support, and you should verify it before committing. A naive implementation downloads the object to local temp first, meaning large files traverse the network twice and fill /tmp. Check whether the rule streams to the scanner. If it doesn't, either restrict scanned fields to local disks or accept the double-transfer cost knowingly.

freshclam is your real operational dependency, not the package. Signatures update multiple times a day and nothing in a Filament plugin does that for you. On a sidecar container the standard image handles it; on a bare VM it's a systemd timer you own. Watch for staleness — an out-of-date clamd misses commodity malware that's months old, and the dashboard won't tell you.

Config caching hides the socket path. php artisan config:cache bakes config/filament-antivirus.php in. Change the socket path without re-caching and you get mystifying connection failures. config:cache in the deploy script, config:clear in your troubleshooting ritual.

Pin the version and pin Filament's minor in CI. v1.0.0 with zero dependents means nobody upstream is catching breakage for you. Lock the package version, and run your upload tests in CI against the Filament version you ship. Budget for vendoring: forking a few hundred lines into your monorepo lets you patch a broken hook without waiting on a maintainer — reasonable to do, unreasonable to skip and then be blocked during a Filament upgrade.

Test the reject path explicitly, but don't make the daemon a test dependency. Use the null driver for 95% of the suite, then write two or three real integration tests against a clamd in CI asserting (a) EICAR is rejected with the expected message and (b) daemon-down behaves the way you documented. The second test catches a silent fail-open regression.

False positives need an escape hatch with an audit trail. Some legitimate file will get flagged — a packed installer, an obfuscated-but-benign script, a PDF with an embedded macro. The quarantine manager is where that happens; confirm it logs who released what and when. If it doesn't, add a quarantine_releases table keyed by file hash with releasing user and justification. That record is a compliance artifact worth more than the UI itself.

Quarantine is a data-retention problem. Quarantined files are simultaneously potentially malicious and potentially user PII. Decide up front: which disk, encrypted at rest, how long before purge, who can read it. Then set a quota — an unmonitored quarantine directory is an unbounded disk-fill waiting to happen.

Treat 1.0.0 as a starting line, not a stability signal. Semver 1.0 is a promise about the API, not evidence of field use. Watch the repository's commit cadence over the next couple of months; a security-path dependency with a single initial release and no issue traffic is a dependency you should be prepared to own. Check the actual git tag date and diff before upgrading, and re-run the EICAR + daemon-down tests after every bump.

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