Guides / Tutorials
Dog breed images in a Laravel app
By the Dog Image API team. Updated .
In Laravel, an Artisan command can download full-size dog breed PNGs with the Http client, save them with the Storage facade, and a Blade component can show the stored file or the free preview with its credit link. The key lives in .env and is read through config.
Why download first
Dog Image API serves AI-generated illustrations of one dog per set, in nine views, on a transparent background. Full-size files are 2048 x 2048 PNGs. They need a key and a plan, and they are meant to be downloaded once and served from your own disk. So you fetch them with a command, not on page load.
This guide targets current Laravel with the default public disk. Before you use a real key, run the command once with a throwaway value to see the expected 401 and confirm the wiring.
Configuration
Put the key in .env, and keep that file out of version control.
DOG_IMAGE_API_KEY=paste-your-key-here
Expose it through config/services.php:
'dog_image' => [
'key' => env('DOG_IMAGE_API_KEY'),
],
Then link the public disk once, so stored files are reachable at /storage:
php artisan storage:link
php artisan make:command FetchDogImages
The Artisan command
Open app/Console/Commands/FetchDogImages.php and replace it with this.
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
class FetchDogImages extends Command
{
protected $signature = 'dogs:fetch {breeds* : Breed slugs, such as pug}
{--views=front,left,head : Comma separated view ids}
{--variant=default}';
protected $description = 'Download full-size dog breed PNGs once and store them.';
public function handle(): int
{
$key = config('services.dog_image.key');
if (! $key) {
$this->error('Set DOG_IMAGE_API_KEY in .env.');
return self::FAILURE;
}
$disk = Storage::disk('public');
$variant = $this->option('variant');
$views = explode(',', $this->option('views'));
foreach ($this->argument('breeds') as $breed) {
foreach ($views as $view) {
$path = "dogs/{$breed}/{$variant}/{$view}.png";
if ($disk->exists($path)) {
$this->line("skip {$path}");
continue;
}
$response = Http::baseUrl('https://dogimage.dev')
->withToken($key)
->timeout(60)
->retry(3, 2000, function ($exception) {
return $exception instanceof RequestException
&& in_array($exception->response->status(), [502, 503], true);
}, throw: false)
->get("/api/v1/images/{$breed}/{$variant}/{$view}", ['size' => 'full']);
if ($response->status() === 429) {
$this->warn('Stopping: '.$response->json('error.message', 'quota_used_up'));
return self::SUCCESS;
}
if (! $response->ok()) {
$this->error(sprintf(
'%d %s: %s', $response->status(),
$response->json('error.code', 'error'),
$response->json('error.message', 'Request failed')
));
return self::FAILURE;
}
$disk->put($path, $response->body());
$this->info("saved {$path}, remaining this month: ".$response->header('X-Quota-Remaining'));
}
}
return self::SUCCESS;
}
}
Run it with php artisan dogs:fetch pug beagle --views=front,left.
What each part does
withToken($key) sends Authorization: Bearer <key>. The retry() call tries up to three times, waits two seconds between tries, and only repeats when the reply is a 502 (file_unavailable) or a 503 (plan_check_failed). Passing throw: false returns the last response instead of an exception, so the code below it can branch on the status.
A 429 is handled before anything else. Its code is quota_used_up, and the command prints the API's message and returns success, because running out of quota is an expected state and not a crash. Every other non-200 status, such as 401, 402 or 404, prints the code and message and fails. The errors and quotas reference lists each one.
Quota headers
$response->header('X-Quota-Remaining') returns a string. On the All images plan it is unlimited. If you want a warning, check before comparing:
$remaining = $response->header('X-Quota-Remaining');
if (ctype_digit($remaining) && (int) $remaining < 10) {
$this->warn('Fewer than 10 images left this month.');
}
The X-Quota-Limit and X-Quota-Used headers sit beside it.
A Blade component
Generate a class-based component:
php artisan make:component DogImage
<?php
namespace App\View\Components;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\Component;
class DogImage extends Component
{
public string $name;
public string $src;
public ?string $creditUrl = null;
public function __construct(string $breed, string $view = 'front', string $variant = 'default')
{
$site = 'https://dogimage.dev';
$path = "dogs/{$breed}/{$variant}/{$view}.png";
$disk = Storage::disk('public');
$this->name = Str::headline($breed);
if ($disk->exists($path)) {
$this->src = $disk->url($path);
} else {
$this->src = "{$site}/api/v1/images/{$breed}/{$variant}/{$view}?size=512";
$this->creditUrl = "{$site}/dogs/{$breed}";
}
}
public function render(): View
{
return view('components.dog-image');
}
}
And the template, resources/views/components/dog-image.blade.php:
<figure>
<img src="{{ $src }}" alt="{{ $name }}, AI-generated illustration" width="512" height="512">
@if ($creditUrl)
<figcaption>
<a href="{{ $creditUrl }}">{{ $name }} image: Dog Image API</a>
</figcaption>
@endif
</figure>
Use it anywhere in a Blade view:
<x-dog-image breed="golden-retriever" view="left" />
When your stored file exists, the component shows it and skips the credit, since full-size files need none. When it does not, the component falls back to the keyless preview and prints the visible credit link the preview terms require. Check the license for the full terms, including no reselling the images as a collection.
Picking breeds and views
Slugs are on the breed index. Views are front, front-left, left, rear-left, rear, rear-right, right, front-right and head. See every breed from one angle on /views/left. Each distinct full-size image counts once per month, so check pricing before you request all nine views for many breeds.
Questions
Why read the key through config() and not env() in the command?
Laravel only guarantees env() inside config files. Once you cache the configuration in production, env() calls elsewhere return null. Read DOG_IMAGE_API_KEY in config/services.php and use config() in the command.
Can I use another Storage disk, such as S3?
Yes. The command uses the public disk, but any disk you configure works, because it only calls exists() and put(). Change the disk name in the command and in the component.
Does the component need a key?
No. The preview is a keyless 512 pixel WebP and the stored file is already on your disk. The key is used only by the Artisan command, which should run on a server or in a deploy step.
What should the command do when the quota runs out?
Stop. A 429 with the code quota_used_up means the monthly count is spent, and retrying does not help. The command prints the message and exits cleanly, leaving the files it already saved.
Next steps
- API docs: endpoints, keys, limits and errors.
- All dog breeds, each with nine views.
- Pricing for full-size transparent PNG files.
- A dog breed card with Tailwind CSS
- A dog breed gallery in SvelteKit
- A dog breed picker in Angular
- A dog breed selector in Vue 3