Secure Uploads - v1.0.0
Custom Scanners
MalwareScannerInterface has three methods. Implement them, bind your class, and the rest of the package picks it up without further changes.
The contract
namespace ArtisanPackUI\SecureUploads\Contracts;
use ArtisanPackUI\SecureUploads\FileUpload\ScanResult;
interface MalwareScannerInterface
{
public function scan(string $filePath): ScanResult;
public function isAvailable(): bool;
public function getName(): string;
}
scan($filePath)— given an absolute path to the file on disk, return aScanResult. Should not throw on infected files — returnScanResultwithinfected = trueinstead. Throwing is reserved for scanner unavailability or transport errors.isAvailable()— quick health check (socket reachable, API key configured, binary on PATH). Used byfailOnScanErrorand by the quarantine command to skip drivers that are down.getName()— short identifier ('clamav','virustotal'). Used inScanResultand in event payloads so listeners can attribute results.
Example: scan via an external HTTP service
namespace App\Scanners;
use ArtisanPackUI\SecureUploads\Contracts\MalwareScannerInterface;
use ArtisanPackUI\SecureUploads\FileUpload\ScanResult;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Http;
class InternalScannerClient implements MalwareScannerInterface
{
public function __construct(
protected string $endpoint,
protected string $apiKey,
protected int $timeoutSeconds = 30,
) {}
public function scan(string $filePath): ScanResult
{
$response = Http::withToken($this->apiKey)
->timeout($this->timeoutSeconds)
->attach('file', fopen($filePath, 'r'), basename($filePath))
->post($this->endpoint . '/scan');
$response->throw(); // unavailability or transport errors raise
$body = $response->json();
return new ScanResult(
infected: $body['infected'] ?? false,
signature: $body['signature'] ?? null,
scanner: $this->getName(),
scannedAt: CarbonImmutable::now(),
raw: $body,
);
}
public function isAvailable(): bool
{
return rescue(
fn () => Http::withToken($this->apiKey)
->timeout(2)
->get($this->endpoint . '/health')
->successful(),
false,
);
}
public function getName(): string
{
return 'internal-scanner';
}
}
Binding your scanner
Two paths.
Path A — replace the default binding
In a service provider's register():
$this->app->singleton(
MalwareScannerInterface::class,
fn ($app) => new InternalScannerClient(
endpoint: config('services.internal_scanner.endpoint'),
apiKey: config('services.internal_scanner.key'),
),
);
Your scanner is now used everywhere the contract is resolved — SecureFileStorageService, the scan.upload middleware, the scan-quarantine command.
Path B — extend the driver switch
If you want to pick the scanner via config (SECURE_UPLOADS_MALWARE_DRIVER=internal-scanner), override the package's service provider binding with a match that includes your driver:
$this->app->extend(
MalwareScannerInterface::class,
function ($default, $app) {
$driver = config('artisanpack.secure-uploads.malwareScanning.driver');
return match ($driver) {
'internal-scanner' => new InternalScannerClient(/* ... */),
default => $default,
};
},
);
The default (from the package's switch) is still available for the original three drivers.
Testing your scanner
The package's NullScanner is a good reference for what a passing scanner looks like. For your own tests, use the EICAR test file (https://www.eicar.org/) — it's a known benign string that triggers every legitimate antivirus.
$scanner = new InternalScannerClient(/* ... */);
$result = $scanner->scan(__DIR__ . '/fixtures/eicar.com.txt');
expect($result->infected)->toBeTrue();
expect($result->signature)->toContain('EICAR');
Conventions
- Fail closed in production. Pair your scanner with
failOnScanError = trueunless you have a specific reason to let uploads through during scanner outages. getName()should be stable. Listeners may filter on it.- Don't throw on infected files. Return
ScanResultwithinfected = true. Throwing is reserved for the scanner itself being broken. - Be fast. Sync scanning blocks the user — anything slower than a few seconds should run async via the quarantine workflow.