ashallendesign/favicon-fetcher
A Laravel package for fetching website favicons. Pass it a URL and it locates and retrieves the site's icon, making it easy to add favicons to bookmarks, link previews, dashboards, and directory listings with a simple API and Laravel integration.
Install the package and publish its config:
composer require ashallendesign/favicon-fetcher
php artisan vendor:publish --tag="favicon-fetcher-config"
The service provider is auto-discovered. The single entry point you'll use 90% of the time is the Favicon facade:
use AshAllenDesign\FaviconFetcher\Facades\Favicon;
$favicon = Favicon::fetch('https://ashallendesign.co.uk');
$favicon->getFaviconUrl(); // https://.../favicon.png
$favicon->getIconSize(); // "32x32" (driver-dependent, may be null)
$favicon->getIconType(); // "image/png"
$favicon->getIconExtension(); // "png"
Look first at config/favicon-fetcher.php. It holds the default driver, a block per driver (favicon-kit, google, duckduckgo, unavatar), cache settings, and the fallback list. Set the default from .env rather than editing the file:
FAVICON_FETCHER_DRIVER=google
The canonical first use case is "show the site icon next to a URL the user gave us" — bookmarks, link shorteners, RSS readers, admin CRUD lists, newsletter link cards. Minimal working flow:
https://example.com/some/deep/page.Favicon::fetch($url) (drivers normalise to the host for you).getFaviconUrl() on your model. Never call fetch() from a Blade template.If you want to inspect what a driver actually returns before committing to a design, run Favicon::fetch('https://github.com') in php artisan tinker and dd() the DTO — the getters are thin, and API-based drivers often leave getIconSize()/getIconType() null.
Store the result, never the call. Add a column and fill it once:
Schema::table('bookmarks', function (Blueprint $table) {
$table->string('favicon_url')->nullable()->after('url');
$table->timestamp('favicon_fetched_at')->nullable();
});
Your index page then just renders <img src="{{ $bookmark->favicon_url }}">, which stays fast and works when the upstream provider is down.
Do the fetch in a queued job. Favicon lookups are third-party network calls; they don't belong in a request cycle that a user is waiting on, and bulk imports will blow past timeouts:
final class FetchBookmarkFavicon implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public array $backoff = [10, 60, 300];
public function __construct(public Bookmark $bookmark) {}
public function handle(): void
{
$favicon = Favicon::fetch($this->bookmark->url);
$this->bookmark->update([
'favicon_url' => $favicon?->getFaviconUrl(),
'favicon_fetched_at' => now(),
]);
}
}
Dispatch from model events (created, and on url change) and add a nightly command that re-fetches rows older than ~30 days.
Wrap driver selection in a resolver. Providers are flaky and inconsistent; a small chain keeps one outage from blanking every icon:
class FaviconResolver
{
public function resolve(string $url): ?string
{
foreach (['google', 'duckduckgo', 'unavatar'] as $driver) {
try {
$favicon = Favicon::driver($driver)->fetch($url);
} catch (Throwable) {
continue;
}
if ($favicon && $this->looksValid($favicon->getFaviconUrl())) {
return $favicon->getFaviconUrl();
}
}
return null;
}
}
Bind this in the container and inject it into the job so tests can swap one class instead of mocking the facade.
Use fetchAll() when the choice matters. For a "pick your icon" UI, or to prefer the largest/highest-quality asset:
$best = Favicon::fetchAll($url)
->sortByDesc(fn ($f) => $this->area($f->getIconSize())) // parse "180x180"
->first();
Fall back to sort order svg → png → ico when sizes are missing.
Blade with an initials fallback. Don't ship broken images:
@if ($bookmark->favicon_url)
<img src="{{ $bookmark->favicon_url }}" alt=""
class="size-8 rounded"
onerror="this.replaceWith(Object.assign(document.createElement('span'),{textContent:'{{ Str::substr($bookmark->title, 0, 1) }}'}))">
@else
<span class="size-8 grid place-items-center rounded bg-gray-200">
{{ Str::upper(Str::substr($bookmark->title, 0, 1)) }}
</span>
@endif
Re-host the icon if you care about privacy or availability. Provider URLs (google.com/s2/favicons, icons.duckduckgo.com, unavatar.io) leak every visitor's IP and referrer to a third party, and can 404 later. Download once in the job and put it on your own disk:
$response = Http::timeout(5)->get($favicon->getFaviconUrl());
if ($response->successful() && $response->body() !== '') {
Storage::disk('public')->put("favicons/{$bookmark->id}.png", $response->body());
}
Custom drivers are the real extension point. If you run an internal icon service or want to scrape <link rel="icon"> yourself, implement the package's fetcher contract (fetch() / fetchAll() returning the Favicon DTO or a collection) and register it under drivers in the config, then select it with Favicon::driver('internal').
Testing. Mock the facade so no test touches the network:
Favicon::shouldReceive('fetch')
->once()
->with('https://example.com')
->andReturn($this->fakeFavicon('https://cdn.test/favicon.png'));
Construct the DTO directly for the fake, or bind a stub implementation of the contract in the container. Assert the job writes the column and that the view renders the fallback on null.
null vs exception. Failing drivers may return null, throw, or return a URL to a generic globe icon. Wrap the call in try/catch (Throwable) and null-check; don't rely on one style.scheme + host (or at least parse with parse_url()) rather than a full deep link. Reject non-http(s) schemes and private/loopback hosts — you're passing a user-controlled string to an external service, and that's an SSRF surface if you ever swap in a self-hosted driver or download the icon yourself.config:cache in production. Driver and cache settings come from the config file; changing FAVICON_FETCHER_DRIVER in .env on a cached deployment does nothing until you re-cache. Put the env var in your deploy checklist.null sentinel with favicon_fetched_at). Otherwise every page load or every re-run of the job re-hits a provider that has already told you "no".getIconSize() frequently returns null or a non-WxH string; don't call explode('x', ...) without a guard. When you save a downloaded file, whitelist the extension (png, ico, svg, jpg, webp) instead of trusting getIconExtension() — it's a string from a third party, not a validated MIME type.<img>. A small number of sites serve SVG favicons that render at unexpected intrinsic sizes. Set explicit width/height on the <img> and don't rely on the icon's own dimensions.favicon-kit has a history of outages and Google's endpoint is undocumented and subject to change. Configure a fallback list, cap concurrency on the queue (a RateLimited job middleware of ~5/s is plenty), and set a short HTTP timeout in the config so a hung provider doesn't stall workers.saving observer — inside a DB transaction, a slow provider holds the transaction open. Use created/updated with dispatch() (after commit) or DB::afterCommit().composer.json and skim the changelog for driver renames before bumping.How can I help you explore Laravel packages today?