snipe/safebrowsing
Laravel integration for Google Safe Browsing that checks URLs against malware and phishing lists. A simple, config-driven API that drops easily into validation or link-scanning workflows, helping block unsafe destinations before users reach them.
Install and wire it up
composer require snipe/safebrowsing
Laravel 5-era package discovery is manual: add the provider to config/app.php (look for a Snipe\Safebrowsing\...ServiceProvider in vendor/snipe/safebrowsing/src/) and, if the package ships one, the facade alias alongside it. Then publish the config:
php artisan vendor:publish
and put your key in .env rather than in the published config file:
GOOGLE_SAFEBROWSING_API_KEY=your-key-here
Get the key from the Google Cloud Console with the Safe Browsing API enabled. You need one key per environment; a key restricted to the wrong API or to a server IP allowlist is the single most common "it just returns 403" cause.
Where to look first
vendor/snipe/safebrowsing/src/ — read the main client class top to bottom. This package is small (12 stars, last release 2016-06-30), so the entire public surface is probably one or two classes. You will learn more in five minutes there than from any docs.config/safebrowsing.php after publishing — this tells you whether the key is read from services.*, a package-specific key, or passed per-call.composer.json — check the illuminate/* and guzzlehttp/guzzle constraints before you commit to it.First use case
The canonical job is checking user-supplied URLs before you render or redirect to them: comment links, profile/avatar URLs, webhook endpoints, or any shortener-style feature. Minimal flow:
// Pseudocode — confirm the exact method names in the package source.
if (Safebrowsing::isSafe($url)) {
// store / redirect
}
If the package only exposes a raw lookup, wrap it yourself on day one:
// app/Services/UrlSafety.php
public function isSafe(string $url): bool
{
try {
$result = $this->client->lookup($url);
return empty($result['matches']);
} catch (\Exception $e) {
report($e);
return false; // fail closed on user-facing redirects
}
}
Bind that wrapper in the container and call it everywhere, not the facade. That gives you one place to swap implementations when (not if) you move off this package.
Validation rule for user input
Wrap the check as a custom rule so it lives with the rest of your validation instead of scattered in controllers:
Validator::extend('safe_url', function ($attribute, $value) {
return app(UrlSafety::class)->isSafe($value);
});
Apply it to URL fields in Form Requests. Be explicit in your error message ("this link has been flagged as unsafe") — the generic "invalid" message confuses users who tested the URL in a browser and saw it load fine.
Middleware for outbound redirects
If you have a /go/{link} style redirector, put the check in middleware ahead of the redirect, not in the controller:
public function handle($request, Closure $next)
{
$target = $request->query('url');
abort_unless(app(UrlSafety::class)->isSafe($target), 403);
return $next($request);
}
This is the highest-value integration because it is the one place where a bad URL actually reaches a victim.
Cache the negatives, queue the positives
Lookups are network calls to Google with quota attached. Cache "safe" results aggressively (a day or so is plenty — most URLs never become unsafe):
return Cache::remember('sb:'.sha1($url), now()->addDay(), fn () => $this->check($url));
Do not cache "unsafe" for long — you want re-checks to clear false positives quickly — and never cache errors as unsafe, or a transient 500 from Google will permanently blacklist a user's URL. Distinguish "no matches" from "request failed" in your wrapper's return type (bool + exception, or an enum), then cache accordingly.
Bulk work belongs in a job
If the package exposes the update/threatLists/fullHashes side of the API, that is a chunked, stateful, rate-limited dance — implement it as an artisan command writing to a table, run by the scheduler. Never in a request cycle.
Testing
The package almost certainly uses Guzzle under the hood, and it predates Laravel's Http::fake(). Mock at the container level instead:
$this->mock(UrlSafety::class, fn ($m) => $m->shouldReceive('isSafe')->andReturn(true));
That is another reason to depend on your own wrapper: you never need to fake the vendor client's internals, and your test suite doesn't break when the package's HTTP layer changes.
The API this was built against is gone. The package's last release is mid-2016, which puts it firmly in the Safe Browsing v3 era. Google shut down the v3 Lookup/Update APIs and moved to v4 (different endpoints, POST with a client object, different match/threatTypes shapes). Treat any working behavior as a pleasant surprise, verify against the live API on day one with a known-bad URL (e.g. a domain from Google's published test list), and budget for replacing the HTTP layer with a handful of direct Guzzle calls if the bundled client 404s. The business logic you need is genuinely small — a POST and a matches check.
API key vs. client ID/secret. v3 required a clientId/clientVersion pair that is now meaningless. If the config asks for those, leave them blank and make sure it isn't also trying to send them in a way that v4 rejects. Expect to edit vendor/ (or fork it) — the published config alone may not be enough.
Version and license friction. GPL-3.0 is a strong copyleft license. Using it as a Composer dependency in a proprietary app is generally fine, but modifying and redistributing it — including vendoring a patched copy into a closed-source product — pulls GPL obligations along. If you fork it to fix the API version, either keep the fork internal or plan to reimplement rather than redistribute. Also check the illuminate/support constraint: it will not install cleanly on modern Laravel without --ignore-platform-reqs shenanigans, and Guzzle 5/6 vs 7 constraints in composer.json will conflict with most of the rest of your lockfile. For a package this size, a fork under repositories with loosened constraints is often less painful than fighting resolution.
Fail open or fail closed — decide explicitly. Safe Browsing being down or rate-limited is not the same as a URL being unsafe. Default to fail closed on user-facing redirects (deny) and fail open on low-stakes display contexts (show the link) — and log every failure either way so a silent quota exhaustion doesn't turn into a mystery "all links are blocked" bug.
Hashing details matter if you touch the update API. The v4 update flow exchanges SHA-256 hashes of URL expressions with prefix/suffix ranges; you must canonicalize URLs (lowercase host, strip fragments, sort query params, handle IDN/punycode) before hashing, and keep entries up to 30 days old. Getting canonicalization wrong produces false negatives that are invisible in testing. If your package's internals already normalize, don't double-normalize.
Debug with Log::debug() around the client call, not dd(). Dump the request URL, HTTP status, and raw response body on the first pass — a raw 403 body from Google tells you exactly which parameter is wrong, and you will not see it once an exception is wrapped.
Extension point. If you must keep the package, the sustainable move is: thin wrapper service → container binding → one place that talks to the vendor client. That is the seam where you swap in a maintained client (or curl/Guzzle directly) later, without touching a single call site. Given the package's age and low maintenance signal, assume that swap is coming — engineer for it now rather than after the v3 endpoints start returning errors in production.
How can I help you explore Laravel packages today?