Laravel 11 · 12 — PHP 8.2+
You pick the photos.
Unsplash serves them.
A curated registry for Laravel. Approve the photos you want, keep them in named pools, and render them hotlinked from Unsplash's CDN with the photographer credited every time — no downloads, no egress bill, and no way to drift out of the API Guidelines by accident.
The picker, working — click the tiles
01 — The smallest real thing
Three lines, and the photo is yours to use
Find a photo, approve it into a pool, then select from that pool wherever you need it. The third line makes no API call at all.
// 1. A person searches and picks one.
$photo = Unsplash::search('mountain road dusk')->first();
// 2. Approve it. This reports the download event.
Unsplash::curate($photo, pool: 'login-backgrounds');
// 3. Use it. No API call happens here.
$background = UnsplashAsset::cachedRandom('login-backgrounds');
unsplash_id iYw4K_akX_U
pool login-backgrounds
status active
author_name Michael Scott
width × height 4000 × 3000
color #262626
disk / path null — nothing was downloaded
the first photo in the picker, above
02 — Rendering
One component, every breakpoint, credit included
Resizing happens on Unsplash's CDN by adding query parameters. The package
never downloads and re-encodes an image, and the ixid that reports the view
back to the photographer survives every manipulation.
<x-unsplash::image
:photo="$photo"
:sizes="[640, 1280, 1920]"
:quality="70" />
<img
src="images.unsplash.com/…&w=1920&q=70&ixid=…"
srcset="… 640w, … 1280w, … 1920w"
style="background-color: #262626"
loading="lazy" decoding="async">
<figcaption>Photo by Michael Scott on Unsplash</figcaption>
</figure>
03 — Honestly
When not to use this
If you need one photo on one page, you do not need this package. Copy the URL
out of Unsplash, put it in your template, write the credit by hand. No dependency, no
table, no API key. And if you only want raw API access, the official
unsplash/unsplash client is thinner than this and has no opinions.
unsplash:verify takes dead photos
out of rotation and a colour fallback keeps pages readable, but it cannot bring the photo
back. If you need bytes you control forever, buy a stock licence and use
spatie/laravel-medialibrary.
Identical to downloading — same pool, same rows, same editorial decisions.
No storage bill, no egress bill. A registry row is a few KB of metadata.
Views reach the photographer, because the tracking parameter is never stripped.
Support this package
Free forever. Not free to maintain.
The work you never see is the work that keeps your login page loading. Unsplash changes
a response shape, a photographer deletes the photo behind your hero, Laravel ships a
major and the HTTP client moves under you. None of that reaches your application,
because unsplash:verify quietly retired the dead photo at 3am and the test
suite caught the rest. It stays MIT either way.
Sponsorship buys the hours that go into reading the API Guidelines again when they change, keeping the compliance suite honest, and testing against every Laravel major before you upgrade to it. Not features — maintenance, which is the part nobody volunteers for. It stays MIT either way.
Star the repo FREE
Thirty seconds, and it is the first signal other developers look at.
Open a good issue FREE
A clear reproduction is worth more than you think.
Sponsor from $5
Recurring support is what makes maintenance plannable rather than occasional.
Company tier
Your logo in the README and on this page, where other teams will see it.
per month · cancel anytime
♥ Become a sponsorCompany tiers get a logo in the README and on this page.
Introduction
What this package is, the problem it solves, and when you should reach for something else.
What it is
UnsplashToolkit keeps a curated registry: a record of the photos your application has approved, grouped into named pools. It stores each photo's metadata and the hotlink URLs Unsplash returned — never the image bytes. Selecting a photo reads your own database, so rendering a page costs no Unsplash API requests at all.
photo.urls properties."
Unsplash API
GuidelinesCuration and storage are two different problems
Most applications that download Unsplash photos are not trying to own files. They are trying to control which photos appear. Those are separable, and only the second one is yours.
| Downloading files | The curated registry | |
|---|---|---|
| Control over which photos appear | yours | identical |
| Hotlinking guideline | violated | satisfied |
| Views credited to the photographer | broken — ixid is lost | works |
| Bytes served by | your disk, your egress bill | Unsplash's CDN, free |
| API calls when a page renders | none | none |
| Cost of approving a photo | 1 call + a full image transfer + an upload | 1 call |
| Responsive sizes | one, baked in at download | any, on demand |
| A photo removed upstream | you still have it | unsplash:verify retires it |
That last row is the whole trade, and it is why unsplash:verify exists.
Do you need this?
If you need one photo on one page, you do not. Copy the URL out of Unsplash and put it in your template:
<img src="https://images.unsplash.com/photo-1506905925346?w=1920&q=80">
Add the credit by hand and you are done. No dependency, no table, no API key.
ixid, because no API request was made to produce
it — you copied it off the website. Attribution is still required either way. As soon as you
are calling the API, the tracking parameter has to survive every resize, which is the part
this package takes off your hands.If you want raw API access and nothing else, the official unsplash/unsplash PHP
client is thinner than this package and has no opinions.
You want this package when a person has to choose the photos, and the application has to keep choosing between them: a rotating login background, approved article headers, seasonal heroes someone in marketing curates.
unsplash:verify takes dead photos out of
rotation and a colour fallback keeps pages readable, but it cannot bring the photo back. If you need
bytes you control forever, buy a stock licence and use spatie/laravel-medialibrary.Requirements
- PHP 8.2, 8.3 or 8.4
- Laravel 11 or 12
- An Unsplash API access key — register an application
Unsplash grants 50 requests per hour in demo mode, and 5000 once your application is approved for production. Because pools are read from your database, that budget is spent when someone curates a photo, never when a page renders one.
Installation
Install, migrate, configure and verify — four commands and two environment variables.
Install the package
composer require gabrielesbaiz/unsplash-toolkitPublish and run the migrations
php artisan vendor:publish --tag="unsplash-toolkit-migrations"
php artisan migrate
Three migrations ship: create_unsplash_assets_table,
create_unsplashables_table, and upgrade_unsplash_tables_to_v2, which is
additive and only does anything on a 1.x installation.
Publish the config
php artisan vendor:publish --tag="unsplash-toolkit-config"
This writes config/unsplash-toolkit.php. Every setting is documented on the
Configuration page.
Set the environment
UNSPLASH_ACCESS_KEY=your-access-key
UNSPLASH_APP_NAME="Your Application"
UNSPLASH_APP_NAME is not optional. It becomes the
utm_source of every photographer credit, which the API Guidelines require. If it is
unset — or still the 1.x your_app_name placeholder — the package throws
MissingAttributionNameException rather than emit a credit that would not qualify.Verify the install
php artisan unsplash:doctor
Rule Status Detail
C7 credentials OK Access key abcd********op is set server side.
C5 attribution OK utm_source is "Your Application".
C1 hotlinking OK Local storage is disabled.
C6 rate limit OK Capped at 50 requests per hour.
It exits non-zero on a violation, so it can gate a deploy. Then curate your first photo:
php artisan unsplash:search "mountain road"
php artisan unsplash:curate <id> --pool=login-backgroundsSchedule the health checks
Because images are hotlinked, a photo removed by its photographer has to be taken out of rotation:
Schedule::command('unsplash:verify')->daily();
Schedule::command('unsplash:refresh')->weekly();Configuration
Every key in config/unsplash-toolkit.php, its default, and what changing it does.
Publishing
php artisan vendor:publish --tag="unsplash-toolkit-config"Config is read through a typed Support\Config class with a single KEY constant, so the published file name and the key the package reads from can never drift apart — which is exactly the bug that made 1.x unable to run at all.
Credentials
| Key | Default | What it does |
|---|---|---|
access_key | UNSPLASH_ACCESS_KEY | Your Unsplash access key. Required |
secret_key | UNSPLASH_SECRET_KEY | Only needed for OAuth flows; redacted from errors |
base_url | https://api.unsplash.com | The API host, for proxies and tests |
api_version | v1 | Sent as the Accept-Version header |
Compliance
| Key | Default | What it does |
|---|---|---|
compliance.app_name | UNSPLASH_APP_NAME | The utm_source of every credit. Required — a missing or placeholder value throws |
compliance.allow_local_storage | false | Permits import() to write image bytes to a disk |
compliance.storage_permission_reference | null | The Unsplash ticket granting that permission. Required when the flag is on |
HTTP
| Key | Default | What it does |
|---|---|---|
http.timeout | 10 | Seconds to wait for a response |
http.connect_timeout | 5 | Seconds to wait for a connection |
http.concurrency | 5 | Requests a pooled batch fires at once |
http.retry.times | 3 | Attempts before giving up |
http.retry.sleep | 250 | Milliseconds between attempts |
http.retry.backoff | true | Doubles the wait each attempt |
Rate limiting
| Key | Default | What it does |
|---|---|---|
rate_limit.enabled | true | Refuses requests over budget before sending them |
rate_limit.key | unsplash-toolkit | The rate limiter key |
rate_limit.max_per_hour | 50 | Your granted budget. Raise to 5000 once approved for production |
Cache
| Key | Default | What it does |
|---|---|---|
cache.enabled | true | Caches API responses. Never caches image bytes or download events |
cache.store | null | The store to use; null means your default |
cache.ttl | 3600 | Seconds a response stays cached |
cache.prefix | unsplash | Cache key prefix |
Pools
| Key | Default | What it does |
|---|---|---|
pools.default | default | The pool used when none is named |
pools.selection_ttl | 600 | Seconds cachedRandom() keeps its choice |
pools.min_size | 5 | Active photos below which PoolDepleted fires |
pools.fallback_color | #0f172a | Painted when a photo has no dominant colour |
Images
| Key | Default | What it does |
|---|---|---|
images.quality | 80 | Default JPEG quality on resized URLs |
images.format | jpg | Default format; webp and avif also work |
images.fit | crop | How Unsplash fits an image to given dimensions |
images.srcset_widths | [640, 960, 1280, 1920, 2560] | Widths a srcset is generated for |
images.lazy | true | Adds loading="lazy" to the image component |
Database
| Key | Default | What it does |
|---|---|---|
database.connection | null | The connection the model uses; null means default |
database.assets_table | unsplash_assets | The registry table |
database.pivot_table | unsplashables | The polymorphic pivot table |
Picker
| Key | Default | What it does |
|---|---|---|
picker.enabled | true | Registers the two proxy routes |
picker.route_prefix | unsplash-toolkit | The URI prefix they live under |
picker.middleware | ['web', 'auth'] | What guards them. Use your admin panel's group |
Storage and queue
| Key | Default | What it does |
|---|---|---|
storage.disk | local | Where import() writes, when permitted |
storage.path | unsplash | The directory it writes into |
queue.connection | null | Connection for ImportUnsplashPhotoJob |
queue.queue | null | Queue for the same |
events | true | Whether the package dispatches its events |
Guide
Browsing, curating, selecting and rendering — the whole working surface of the package.
Browsing Unsplash
Every browse method returns a PendingRequest you refine with chained calls, then
execute with ->get(), ->first() or ->toArray().
use Gabrielesbaiz\UnsplashToolkit\Facades\Unsplash;
use Gabrielesbaiz\UnsplashToolkit\Enums\{Color, Orientation, OrderBy};
$results = Unsplash::search('buildings')
->color(Color::BlackAndWhite)
->orientation(Orientation::Squarish)
->orderBy(OrderBy::Relevant)
->perPage(30)
->get();
$results->total; // 1337
$results->totalPages;
$results->hasMorePages();
$results->photos->ids(); // ['Dwu85P9SOIk', …]
foreach ($results as $photo) { $photo->id; }
Enums are optional — plain strings work too, which matters when the value comes from a request:
->color($request->string('color')).
Other entry points
Unsplash::photo('Dwu85P9SOIk'); // Photo
Unsplash::photosByIds([$a, $b, $c]); // pooled, concurrent
Unsplash::random()->term('car')->count(5)->get();
Unsplash::collection($id);
Unsplash::collectionPhotos($id)->get();
Unsplash::userPhotos('ashim')->get();
Unsplash::stats();
Unsplash::random() in a page render. It spends
an API request on every page view, and the guidelines ask for non-automated use. Curate the photos
you like, then select from the pool.The builder is immutable
Every modifier returns a new instance, so one base request can safely branch:
$base = Unsplash::search('cars')->perPage(30);
$wide = $base->orientation(Orientation::Landscape)->get();
$tall = $base->orientation(Orientation::Portrait)->get();
// $base is untouched, and $wide's orientation never reaches $tall.
In 1.x the facade cached one mutable instance, so search()->term('cats') followed
by photos() sent photos?query=cats. That class of bug cannot happen now.
Working with a photo
$photo->url(Size::Regular); // exactly what Unsplash returned
$photo->url(width: 1920, quality: 70); // resized on Unsplash's CDN
$photo->srcset([640, 1280, 1920]);
$photo->aspectRatio();
$photo->alt();
$photo->attribution()->toHtml();
$photo->color; $photo->blurHash; $photo->author->name; $photo->tags;
The five sizes are Raw, Full, Regular, Small
and Thumb. Resizing happens on Unsplash's servers by adding query parameters; the
package never downloads and re-encodes an image, and every URL keeps its ixid.
Curating
Approving a photo is the moment a person chooses it, which is exactly the event Unsplash asks to be told about. Curating reports it for you.
$asset = Unsplash::curate($photo, pool: 'login-backgrounds');
Unsplash::curateMany($photos, pool: 'hero');
Unsplash::curateCollection('1234567', pool: 'hero');
Unsplash::curate($photo, pool: 'hero', curatedBy: auth()->user());
Curating the same photo twice updates the existing row rather than duplicating it —
unsplash_id is unique.
Pools and selection
UnsplashAsset::random('login-backgrounds'); // two queries, no HTTP
UnsplashAsset::cachedRandom('login-backgrounds'); // one query on a cache hit
UnsplashAsset::randomOrFail('login-backgrounds'); // throws when empty
UnsplashAsset::pool('hero')->active()->get();
All of them skip photos whose status is not active, so a photo retired by
unsplash:verify stops appearing with no change at the call site.
cachedRandom() re-checks the cached photo is still selectable and drops the entry if it
is not.
Rendering
<x-unsplash::image :photo="$photo" :sizes="[640, 1280, 1920]" />
<x-unsplash::attribution :asset="$photo" />
| Prop | Default | What it does |
|---|---|---|
photo | null | The photo. Null renders nothing at all |
sizes | images.srcset_widths | Widths the srcset is generated for |
sizes-attribute | 100vw | The sizes HTML attribute |
width / height | null | Fixed dimensions for the src and element |
quality | images.quality | JPEG quality, 1–100 |
alt | the photo's own | Overrides the alternative text |
attribution | true | Renders the credit inline |
lazy | images.lazy | Adds loading="lazy" |
Both components render nothing when given null, so a page whose pool is empty loses
its image rather than failing.
Attaching photos to your models
use Gabrielesbaiz\UnsplashToolkit\Concerns\HasUnsplashables;
class Article extends Model
{
use HasUnsplashables;
}
$article->unsplash()->attach($asset);
$article->unsplashPhoto();
Article::with('unsplash')->get(); // no N+1
The trait boots through bootHasUnsplashables(), so a model with its own
boot() keeps it. The 1.x trait declared boot() and silently replaced yours.
Building a picker
Two routes back an admin picker, so a client-side field never needs your access key. They live
under picker.route_prefix, guarded by picker.middleware.
GET {prefix}/search — query, plus optional page,
per_page (max 30), orientation, color:
{
"total": 132, "total_pages": 6, "page": 1,
"results": [{
"id": "Dwu85P9SOIk",
"thumb": "https://images.unsplash.com/…?ixid=…&w=400",
"width": 4000, "height": 2500, "aspect_ratio": 1.6,
"color": "#26260c", "blur_hash": "LFC$yHwc8^$yIAS$%M",
"author": "…", "author_url": "…utm_source=…&utm_medium=referral",
"attribution_html": "Photo by …"
}]
}
attribution_html, or build
your own markup from author and author_url, but never reconstruct the UTM
parameters in client code.POST {prefix}/curate — ids (1–30), optional pool —
returns assets[] carrying both the unsplash_id and the primary
id a form field attaches to a record.
Testing
$fake = Unsplash::fake();
Unsplash::curate(Unsplash::photo('Dwu85P9SOIk'), 'hero');
expect($fake->sentRequestTo('/download'))->toBeTrue();
expect($fake->requestCount())->toBe(2);
Http::fake() also works throughout, because the driver uses Laravel's HTTP client
rather than Guzzle directly. The factory builds registry rows without touching the API:
UnsplashAsset::factory()->count(10)->pool('hero')->create().
Recipes
Whole, paste-able solutions to the tasks this package actually gets used for.
Eight recipes
A rotating login background across a multi-tenant app
// Once, when an editor approves photos:
Unsplash::curate($photo, pool: "login-{$tenant->slug}");
// In a shared helper, so every controller resolves it the same way:
function currentWallpaper(): ?UnsplashAsset
{
return UnsplashAsset::cachedRandom('login-'.tenant()->slug);
}
<div class="login" style="background-image: url('{{ $wallpaper?->url(width: 1920, quality: 80) }}')">
<x-unsplash::attribution :asset="$wallpaper" />
</div>
No API call, one cached query, and the credit disappears rather than crashing when the pool is empty.
Serve the background through a route instead of inline CSS
Route::get('wallpaper', function () {
$wallpaper = UnsplashAsset::cachedRandom('login-backgrounds');
abort_if($wallpaper === null, 404);
return redirect()->away($wallpaper->url(width: 1920, quality: 80));
});
The redirect points at Unsplash's CDN, so your server never carries the image.
Let an editor curate from your admin panel
const res = await fetch(`/unsplash-toolkit/search?query=${term}&per_page=24`)
const { results } = await res.json()
// Render each tile with its aspect_ratio and color,
// and results[i].attribution_html underneath it.
await fetch('/unsplash-toolkit/curate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': token },
body: JSON.stringify({ ids: selected, pool: 'hero' }),
})
The response gives you assets[].id, the primary keys to attach to a record.
Give an article a header photo
class Article extends Model
{
use HasUnsplashables;
}
$article->unsplash()->sync([$request->integer('unsplash_asset_id')]);
@if ($photo = $article->unsplashPhoto())
<x-unsplash::image :photo="$photo" :sizes="[640, 1280, 1920]" />
@endif
Use Article::with('unsplash')->get() on index pages to avoid an N+1.
Promote a collection your team curates on unsplash.com
Unsplash::curateCollection('1234567', pool: 'hero');
// Or keep it in step, if the collection is maintained continuously:
Schedule::call(fn () => Unsplash::curateCollection('1234567', 'hero'))->daily();
Existing photos are updated rather than duplicated.
Curate in the background from a queue
use Gabrielesbaiz\UnsplashToolkit\Jobs\ImportUnsplashPhotoJob;
Bus::batch(
collect($ids)->map(fn (string $id) => new ImportUnsplashPhotoJob($id, pool: 'hero'))
)->dispatch();
Each job reports its own download event, so a long selection does not block a web request.
Alert editors before a pool runs dry
use Gabrielesbaiz\UnsplashToolkit\Events\PoolDepleted;
Event::listen(function (PoolDepleted $event) {
Notification::route('slack', config('services.slack.editors'))
->notify(new PoolRunningLow($event->pool, $event->remaining, $event->minimum));
});
Pair it with Schedule::command('unsplash:verify')->daily(), which is what retires
removed photos and therefore what shrinks a pool.
Gate a deploy on compliance
- name: Unsplash guidelines
run: php artisan unsplash:doctor --strict
Exits non-zero on a missing app name, an unset key, storage enabled without a recorded
permission, or URLs that have lost their ixid.
Commands
Nine artisan commands. Two of them belong in your scheduler, and one belongs in CI.
All commands
| Signature | What it does |
|---|---|
unsplash:search {query} {--per-page=10} {--orientation=} {--color=} | Search photos and print a table of ids |
unsplash:curate {id*} {--pool=} | Approve one or more photos into a pool |
unsplash:pools | Show the size and health of every pool |
unsplash:verify {--pool=} {--stale=7} | Re-check photos still exist, retiring those that do not |
unsplash:refresh {--pool=} {--backfill} {--assign-pool=} | Refresh metadata; --backfill rebuilds rows with no URLs |
unsplash:status | Show the rate limit and what is spent |
unsplash:doctor {--strict} | Audit the app against the guidelines; exits non-zero on a violation |
unsplash:cache-clear | Flush cached API responses |
unsplash:prune {--pool=} {--days=90} | Delete photos unavailable for that long |
In the scheduler
Because images are hotlinked, a photo removed by its photographer has to be taken out of rotation, and a photographer who renames has to be re-credited correctly.
Schedule::command('unsplash:verify')->daily();
Schedule::command('unsplash:refresh')->weekly();In CI
- name: Unsplash guidelines
run: php artisan unsplash:doctor --strict
It fails on a missing app name, an access key that is not set, storage enabled without a recorded
permission, photos producing URLs with no ixid, and — under --strict —
pools running low.
The backfill
A 1.x installation already holds unsplash_id on every row, which is all that is
needed to rebuild it as a registry entry. Nothing you curated is lost.
php artisan unsplash:refresh --backfill --assign-pool=login-backgroundsPerformance
What the package does to keep your hourly budget intact and your pages fast.
Response caching
Every GET is cached by endpoint and parameters. Only JSON metadata is cached —
image bytes are never cached or stored, because they are never fetched in the first place.
'cache' => [
'enabled' => true,
'store' => null, // null uses your default store
'ttl' => 3600,
'prefix' => 'unsplash',
],
A repeated search costs nothing:
Unsplash::search('cars')->perPage(10)->get(); // one HTTP request
Unsplash::search('cars')->perPage(10)->get(); // served from cache
download_location.php artisan unsplash:cache-clearConcurrent fetches
photosByIds() pools its requests rather than issuing them one after another, in
batches of http.concurrency. Ids already in the cache are not re-fetched at all.
$photos = Unsplash::photosByIds($ids); // one pooled batch, not N round trips
It is built on Laravel's Http::pool(), so a slow photo does not hold up the rest of
the batch.
Retries and timeouts
'http' => [
'timeout' => 10,
'connect_timeout' => 5,
'concurrency' => 5,
'retry' => ['times' => 3, 'sleep' => 250, 'backoff' => true],
],
| Failure | Retried? | Why |
|---|---|---|
| Connection error | yes | Transient; backoff doubles the wait each attempt |
5xx | yes | Unsplash's side, usually momentary |
4xx | no | A malformed request will not succeed on a second attempt |
| Rate limited | no | The budget is already spent; retrying only spends more of it |
v1 had no timeout at all, so a hung connection blocked the worker indefinitely.
Rate limiting
A local limiter keeps you inside the budget Unsplash granted, and refuses the request before it is sent rather than discovering the limit remotely.
'rate_limit' => [
'enabled' => true,
'key' => 'unsplash-toolkit',
'max_per_hour' => 50, // 5000 once approved for production
],
Unsplash::throttle()->used(); // requests spent this window
Unsplash::throttle()->remaining();
Unsplash::throttle()->availableIn(); // seconds until the window resets
$reported = Unsplash::rateLimit(); // what Unsplash said on the last response
$reported?->limit;
$reported?->remaining;
$reported?->isDemo();
$reported?->isExhausted();
php artisan unsplash:status
Exceeding it raises RateLimitExceededException, which carries
retryAfter when Unsplash sent the header.
Why rendering is free
The hourly budget is spent when someone curates a photo. Selecting one from a pool reads your own database, so no page view ever touches the API:
$photo = UnsplashAsset::cachedRandom('login-backgrounds');
// one query on a cache hit, zero HTTP requests
If you are hitting the rate limit during normal page rendering, something is calling the API on
render — look for Unsplash::random() or Unsplash::photo() in a controller
or Blade view.
Troubleshooting
The errors and symptoms you are most likely to meet, and what each one means.
Common problems
MissingAttributionNameException
compliance.app_name is unset, or still the 1.x your_app_name
placeholder. Set UNSPLASH_APP_NAME to your real application name: it becomes the
utm_source on every credit, which the guidelines require, so the package throws rather
than emit a credit that would not qualify.
If you are upgrading, note that 1.x read this key from the wrong config path, so it was silently
null the whole time — your old credits were not tagged either.
HotlinkingRequiredException when calling import()
Working as intended. Images must be hotlinked, so import() is off by default.
Use Unsplash::curate() and <x-unsplash::image> instead.
If Unsplash has given you written permission to store copies, set both
compliance.allow_local_storage and
compliance.storage_permission_reference. Setting only the first still throws, on
purpose — the exception has to stay auditable.
RateLimitExceededException
You have spent the hourly budget. php artisan unsplash:status shows what is
left and when the window resets.
Demo applications get 50 requests an hour. If you are hitting that during normal page rendering,
something is calling the API on render — look for Unsplash::random() or
Unsplash::photo() in a controller or Blade view, and curate into a pool instead. Pool
selection makes no API calls at all.
My background disappeared / cachedRandom() returns null
The pool has no active photos. Either nothing has been curated into that pool
name, or unsplash:verify retired them because they no longer exist on Unsplash.
php artisan unsplash:pools
That shows active, unavailable and archived counts per pool. Watch for a typo in the pool name —
login-background and login-backgrounds are two pools.
Images 404 after working for months
The photographer removed the photo from Unsplash. Because the guidelines require hotlinking, there is no local copy to fall back on. Schedule the check so it is caught before a visitor sees it:
Schedule::command('unsplash:verify')->daily();
Retired photos are skipped by every selection scope, so the pool simply serves another one.
PhotoNotFoundException from unsplash:curate
The id does not exist. Unsplash ids are short base62 strings like Dwu85P9SOIk,
not the numbers in a photo's page URL slug. Copy the id from php artisan unsplash:search,
or from the last path segment of the photo's Unsplash URL.
The picker routes return 401, or do not exist
They are guarded by picker.middleware, which defaults to
['web', 'auth']. If your admin panel authenticates on a different guard, set the
middleware to that panel's group.
If php artisan route:list does not show them at all, either
picker.enabled is false, or your routes are cached — the package skips
registration when routesAreCached() is true, so run
php artisan route:clear.
My config changes are not taking effect
Two likely causes. Run php artisan config:clear if the config is cached. And
check the file name: it must be config/unsplash-toolkit.php. A
config/unsplash.php left over from 1.x is read by nothing in 2.x — delete it.
Class "Laravel\Nova\Actions\Action" not found
Nova\Actions\CurateUnsplashPhotos is an optional integration and Nova is not a
dependency of this package. Only reference that class in applications that have Nova installed.
API reference
Every public method, what it returns, and what it does.
The Unsplash facade
| Method | Returns | What it does |
|---|---|---|
search(?string $term = null) | PendingRequest | Search photos |
searchCollections(?string $term = null) | PendingRequest | Search collections |
searchUsers(?string $term = null) | PendingRequest | Search users, raw payload |
photos() | PendingRequest | List photos |
photo(string $id) | Photo | One photo |
photosByIds(array $ids) | PhotoCollection | Several photos, pooled concurrently |
random() | PendingRequest | Random photos |
photoStatistics(string $id) | PendingRequest | A photo's statistics, raw payload |
user(string $username) | PendingRequest | A user profile, raw payload |
userPhotos(string $username) | PendingRequest | A user's photos |
userLikes(string $username) | PendingRequest | A user's likes |
userCollections(string $username) | PendingRequest | A user's collections |
collections() | PendingRequest | List collections |
collection(string $id) | CollectionResource | One collection |
collectionPhotos(string $id) | PendingRequest | Photos in a collection |
stats() | Stats | Unsplash platform statistics |
curate(Photo $photo, ?string $pool, ?Model $curatedBy) | UnsplashAsset | Approve a photo, reporting the download event |
curateMany(iterable $photos, ?string $pool, ?Model $curatedBy) | Collection | Approve several chosen photos |
curateCollection(string $id, ?string $pool, int $perPage = 30) | Collection | Approve an Unsplash collection |
trackDownload(Photo $photo) | void | Report a download event by hand |
refresh(UnsplashAsset $asset) | UnsplashAsset | Re-fetch metadata, retiring removed photos |
fromPool(?string $pool = null) | ?UnsplashAsset | One cached random photo from a pool |
import(Photo $photo, Size $size, ?string $pool) | UnsplashAsset | Download bytes — gated |
rateLimit() | ?RateLimit | What Unsplash reported on the last response |
throttle() | Throttle | The local rate limiter |
compliance() | Compliance | The guideline guard |
config() | Config | Typed configuration |
client() | UnsplashClient | The underlying API client |
request(string $endpoint, string $shape = 'photos') | PendingRequest | Any endpoint not covered above |
fake(?FakeUnsplash $fake = null) | FakeUnsplash | Replace the client in tests |
PendingRequest
Every modifier returns a new instance, so a base request can be branched safely.
| Method | Parameter sent |
|---|---|
page(int) | page |
perPage(int) | per_page |
count(int) | count |
term(string) | query |
orderBy(OrderBy|string) | order_by |
orientation(Orientation|string) | orientation |
color(Color|string) | color |
contentFilter(ContentFilter|string) | content_filter |
collections(array|string) | collections |
username(string) | username |
featured(bool = true) | featured |
withQuery(string, mixed) | anything |
get() | executes, returns the shaped result |
first() | executes, returns ?Photo |
toArray() | executes, returns the raw payload |
query() / endpoint() | inspect without sending |
UnsplashAsset
| Method | Returns | What it does |
|---|---|---|
random(?string $pool = null) | ?static | One random active photo |
randomOrFail(?string $pool = null) | static | The same, throwing PoolDepletedException when empty |
cachedRandom(?string $pool = null, ?int $ttl = null) | ?static | The same, cached for pools.selection_ttl |
scopePool(?string $pool = null) | scope | Restrict to a pool |
scopeActive() | scope | Restrict to servable photos |
url(Size, ?int $width, ?int $height, ?int $quality, ?string $format, ?string $fit, ?int $dpr) | string | A hotlinked URL |
rawUrl(Size $size = Size::Regular) | string | The URL Unsplash returned, untouched |
srcset(?array $widths, bool $preserveAspectRatio = true, ?int $quality) | string | A responsive srcset |
aspectRatio() | ?float | Width over height |
attribution() | Attribution | The photographer credit |
alt() | string | The best available alternative text |
placeholderColor() | string | The dominant colour, or the configured fallback |
isSelectable() | bool | Whether it may still be served |
unsplashUrl() | string | The photo's page on Unsplash |
curatedBy() | MorphTo | The model that approved this photo |
attachedTo(string $related) | MorphToMany | Models of a type this photo is attached to |
detachAll() | void | Remove every attachment |
attributesFrom(Photo $photo) | array | The attributes describing a photo |
Data objects
All readonly, all implementing Arrayable, ArrayAccess,
Jsonable, JsonSerializable and Stringable where it makes
sense.
| Class | Carries |
|---|---|
Photo | id, urls, author, downloadLocation, width, height, color, blurHash, description, altDescription, tags, likes, raw |
Author | id, username, name, link, portfolioUrl, bio, location |
PhotoCollection | A Laravel collection of Photo, plus ids() |
SearchResult | photos, total, totalPages, page, hasMorePages() |
CollectionResource | id, title, totalPhotos, curator, coverPhoto |
Attribution | authorUrl(), unsplashUrl(), toText(), toHtml(), toHtmlString() |
RateLimit | limit, remaining, used(), isDemo(), isExhausted() |
Stats | photos, downloads, views, photographers |
Enums
| Enum | Cases |
|---|---|
Size | Raw, Full, Regular, Small, Thumb |
Orientation | Landscape, Portrait, Squarish |
Color | BlackAndWhite, Black, White, Yellow, Orange, Red, Purple, Magenta, Green, Teal, Blue |
OrderBy | Latest, Oldest, Popular, Views, Downloads, Relevant |
ContentFilter | Low, High |
AssetStatus | Active, Unavailable, Archived |
Events and exceptions
| Event | Fired when | Properties |
|---|---|---|
PhotoCurated | A photo is approved | $asset |
PhotoUnavailable | A photo no longer resolves | $asset, $reason |
PoolDepleted | A pool drops below pools.min_size | $pool, $remaining, $minimum |
Every exception extends UnsplashException:
MissingAccessKeyException, MissingAttributionNameException,
HotlinkingRequiredException, RateLimitExceededException,
PhotoNotFoundException, PoolDepletedException,
DownloadFailedException.
Compliance
The Unsplash API Guidelines are enforced by the package, not left to the caller. Each rule has a test named after it.
The nine rules
| # | Rule | How it is enforced |
|---|---|---|
| C1 | Hotlink the URLs under photo.urls | The default and only zero-config path; the registry stores URLs, not files |
| C2 | Do not keep copies without permission | import() throws unless permission is configured and recorded |
| C3 | Report a download event | Automatic on curation, to links.download_location |
| C4 | Keep ixid on every manipulation | URLs are rebuilt by merging query strings, so it survives by construction |
| C5 | Credit the photographer and Unsplash | Escaped, UTM-tagged, rendered by default, throws without an app name |
| C6 | Stay inside the rate limit | Local throttle; a rate-limited response is never retried |
| C7 | Keep your keys confidential | Header auth, redacted from errors, picker proxies server side |
| C8 | Non-automated, authentic use | No entry point fetches and stores an arbitrary number of photos |
| C9 | Do not replicate Unsplash | The picker chooses an image for a record; it is not a browsable gallery |
Three more are yours to honour, because no package can check them: do not put "Unsplash" in your application's name or use their logo as your icon; do not sell unaltered Unsplash photos; and do not make users register separately to use the integration.
The doctor command
php artisan unsplash:doctor
php artisan unsplash:doctor --strict # warnings fail too
Rule Status Detail
C7 credentials OK Access key abcd********op is set server side.
C5 attribution OK utm_source is "Your Application".
C1 hotlinking OK Local storage is disabled, so images are served by Unsplash.
C4 tracking OK All 24 sampled photos keep their ixid when resized.
C3 download events OK All 24 curated photos carry a download location.
C6 rate limit OK Capped at 50 requests per hour.
Pool health OK 2 pools checked.
It exits non-zero on a violation, so it can gate a deploy.
The compliance suite
tests/Compliance holds one file per rule. It asserts that hotlink URLs come back
unmodified, that ixid survives every resize and srcset entry, that curation pings
links.download_location before writing a row, that attribution escapes a malicious
author name and carries both UTM parameters, that a rate-limited response is not retried, and that
no bulk-harvesting entry point exists.
it('refuses to download image bytes by default', function () {
Unsplash::fake();
Unsplash::import(Photo::fromResponse(FakeUnsplash::photoPayload()));
})->throws(HotlinkingRequiredException::class, 'hotlinked image URLs');Storing image files
Keeping copies is only permissible with written permission from Unsplash. If you have it, both keys are required — enabling the flag alone still throws, so the exception stays auditable.
'compliance' => [
'allow_local_storage' => true,
'storage_permission_reference' => 'UNSPLASH-1234',
],
The transfer is then streamed to a temporary file and written with
Storage::writeStream(), so a full-resolution photo does not have to fit in
memory_limit. File names are derived from the photo id and size, so importing twice
overwrites rather than probing the disk for a free name.
Security
The package sends your access key to one host and renders third-party strings into your pages. Both are handled deliberately.
The threat model
An access key that, if leaked, lets anyone spend your API budget or impersonate your application to Unsplash.
Author names, descriptions and profile links come from an API you do not control and are rendered as HTML.
What the package does about it
- The access key never reaches a browser. It is sent as an
Authorizationheader, never a query parameter, and the picker exists precisely so a client-side field can search without it. An arch test asserts no shipped view references the config key. - Credentials are redacted from exceptions and logs. An upstream error body that happens to contain your key is scrubbed before it is thrown.
- Author names and links are escaped.
Attributionescapes both and addsrel="noopener noreferrer". The 1.xgetFullCopyrightLink()interpolated them raw, which was an XSS vector. - Picker input is validated. Search terms are length-bounded,
per_pageis capped at 30, and a curate request may name at most 30 ids, so the proxy cannot be used to fan out requests against your API budget. - The picker routes are authenticated by default. They proxy your API quota; leaving them open would let anyone spend it.
- The registry stores no binary content, so on the hotlinking default there is no upload path and nothing user-supplied is written to a disk.
The trade, stated plainly
images.unsplash.com at render time, so
your pages depend on a third-party CDN being up, and a visitor's browser makes a request to
Unsplash. That is what the API Guidelines require. If your threat model forbids third-party image
hosts, this package is not the right fit.Reporting a vulnerability
Please review the project's security policy. Do not open a public issue.
Upgrading from 1.x
Version 2 is a rewrite. The public API changed, and so did what the package stores.
Why the API changed
Three defects in 1.x could not be fixed without breaking the surface:
- Every config read used
config('unsplash.*')while the published file wasunsplash-toolkit.php, so the access key was alwaysnulland the package threw on construction. - The facade cached one instance whose
$querywas never reset, so parameters leaked between calls:search()->term('cats')followed byphotos()sentphotos?query=cats. toJson(): arrayreturned astdClass, andstore(): stringreturned a model. Both raised aTypeErroron the documented happy path.
Why storage changed
The guidelines state that all API uses must hotlink the URLs returned under
photo.urls. store() copied the bytes to your disk and served them from
there, which is the opposite. v2 hotlinks by default and keeps a curated registry instead: you
still decide exactly which photos appear, but Unsplash serves them.
Step by step
1 · Update the config
Delete any hand-made config/unsplash.php you created to work around the key
mismatch, then publish the real file:
php artisan vendor:publish --tag=unsplash-toolkit-config --force
| 1.x | 2.x |
|---|---|
UNSPLASH_ACCESS_KEY | unchanged |
UNSPLASH_APP_NAME | unchanged, but now required and actually read |
UNSPLASH_STORE_IN_DATABASE | removed: photos are always recorded |
UNSPLASH_STORAGE_DISK | only used on the gated storage path |
2 · Run the migrations
php artisan vendor:publish --tag=unsplash-toolkit-migrations
php artisan migrate
upgrade_unsplash_tables_to_v2 is additive. It adds the registry columns, gives
unsplashables the primary key and indexes it shipped without, and renames the
non-idiomatic unsplashables_id / unsplashables_type columns to
unsplashable_id / unsplashable_type.
3 · Backfill the registry
unsplash_id, which is all that is needed to
rebuild them. Nothing you curated is lost.php artisan unsplash:refresh --backfill --assign-pool=default
4 · Update your calls
| 1.x | 2.x |
|---|---|
UnsplashToolkit::search()->term('x')->toJson() | Unsplash::search('x')->get() |
UnsplashToolkit::photo($id)->toJson() | Unsplash::photo($id) |
UnsplashToolkit::randomPhoto()->term('x')->toJson() | Unsplash::random()->term('x')->get() |
UnsplashToolkit::collectionsList()->toJson() | Unsplash::collections()->get() |
UnsplashToolkit::showCollection($id)->toJson() | Unsplash::collection($id) |
UnsplashToolkit::totalStats()->toJson() | Unsplash::stats() |
UnsplashToolkit::trackPhotoDownload($id) | automatic on Unsplash::curate() |
$result['urls']['regular'] | $photo->url(Size::Regular) |
$result['user']['name'] | $photo->author->name |
->store(null, 'regular') | Unsplash::curate($photo, pool: '…') |
$asset->getFullCopyrightLink() | <x-unsplash::attribution :asset="$asset" /> |
Traits\HasUnsplashables | Concerns\HasUnsplashables |
Facades\UnsplashToolkit | Facades\Unsplash |
5 · Serve hotlinked images
- return redirect()->away(Storage::disk('wallpapers')->url($wallpaper->name));
+ return redirect()->away($wallpaper->url(width: 1920, quality: 80));
6 · Schedule the health checks
Schedule::command('unsplash:verify')->daily();
Schedule::command('unsplash:refresh')->weekly();
7 · Check your work
php artisan unsplash:doctorChangelog
All notable changes to unsplash-toolkit.
2.0.4 — 2026-09-28
Fixed
Throttle::used()andThrottle::availableIn()are typedint, butRateLimiterhands back whatever the cache store holds and Redis holds strings. An application on the redis driver hit aTypeErrorwhere one on the array driver saw an int. The same value also reachedRateLimitExceededException, whoseretryAfteris typedint, so the throttle raised aTypeErrorinstead of the rate limit exception exactly when the budget ran out.
2.0.3 — 2026-09-28
Fixed
Four defects in the 1.x upgrade migration, all found by running it against a real 1.x
installation. The path is now covered by tests/Feature/UpgradeMigrationTest.php.
- v1 stored the photographer in
author; v2 readsauthor_name. The column was never mapped, so upgraded rows credited nobody andattribution()fataled. - v1's
nameandauthorcolumns areNOT NULLand v2 writes neither, so every insert after the upgrade failed. They are now made nullable. - The pivot's primary key was detected by looking for a column called
id. An installation that had added its own corrective key under another name got a second auto-increment, which the database rejects. Any primary key now counts. - SQLite cannot add a primary key to an existing table, so the pivot is rebuilt and its rows copied there instead.
attribution()no longer fatals on a row that has not been backfilled yet.
2.0.2 — 2026-09-28
Fixed
- Declared the HTTP dependencies the package actually uses. It builds on
Illuminate\Http\Clientbut required onlyilluminate/contracts, so on a lowest-version resolutionguzzlehttp/promises1.x was installed, whose untypedwait()is incompatible with Laravel 12'sLazyPromiseand fataled on anyHttp::pool()call.guzzlehttp/guzzle,illuminate/httpandilluminate/supportare now required explicitly.
2.0.1 — 2026-09-28
Fixed
- Declared Laravel 11 as the minimum.
UnsplashAssetdefines its casts through thecasts()method, which Laravel 10 does not call, so on Laravel 10 the enum, array and date casts were silently ignored. Laravel 10 reached end of life in February 2026. - Annotated the Blade components' view names as
view-string, so PHPStan passes on a clean dependency resolution. - Removed the Laravel 10 jobs from the test matrix:
pestphp/pest-plugin-laravel ^3requires Laravel 11, so those jobs could never resolve.
2.0.0 — 2026-09-28
A rewrite. See Upgrading from 1.x for the migration path.
Fixed
- The package could not run at all: every config read used
config('unsplash.*')while the published file wasunsplash-toolkit.php, so the access key was alwaysnulland the constructor threw on every instantiation. toJson(): arrayreturned astdClassbecausejson_decodewas called withouttrue, raising aTypeErroron the documented happy path.store(): stringreturned anUnsplashAssetwhenstore_in_databasewas on.- Query parameters leaked between calls, because the facade cached one instance whose
$querywas never reset. HasUnsplashables::boot()overrode the host model's ownboot(); it is nowbootHasUnsplashables().getFullCopyrightLink()interpolated third-party author names and links into HTML unescaped.UnsplashAsset::assets(Model $model)was a relation method taking an argument, so it could not be eager-loaded.- The
unsplashablestable shipped with no primary key and no indexes. unsplash_idwas typeduuid, which fails outright on PostgreSQL; Unsplash ids are short base62 strings.- The
phpconstraint^7.3,^8.0|^8.1|…was an empty set.
Added
- Curated pools: approve photos into named sets and select from your own database, with no API calls at render time.
- Readonly DTOs (
Photo,Author,SearchResult,Attribution,RateLimit) and enums (Size,Orientation,Color,OrderBy,AssetStatus). - Response caching, retries with exponential backoff, timeouts, rate-limit handling and concurrent batch fetches.
<x-unsplash::image>and<x-unsplash::attribution>Blade components.- A picker proxy route that keeps the access key server side, returning layout metadata (aspect ratio, dominant colour, blurhash) and pre-tagged attribution so a client-side field can credit photographers correctly, plus the primary keys a form field attaches to a record.
- Commands:
search,curate,pools,verify,refresh,status,doctor,cache-clear,prune. Unsplash::fake()and a compliance test suite covering each guideline.
Changed
- Images are hotlinked by default, as the Unsplash API Guidelines require. Downloading bytes to a local disk is now gated behind explicit permission.
- Download events use
photo.links.download_locationrather than a hand-built URL, so theixidis no longer dropped. - Attribution requires an application name; a missing or placeholder value throws rather than emitting a credit that does not qualify.
- The facade is now
Facades\Unsplash, and the trait moved toConcerns\HasUnsplashables. - Laravel's HTTP client replaces raw Guzzle, so
Http::fake()works in tests.
1.0.0 — 2025-03-04
Initial release. Forked from marksitko/laravel-unsplash.
Project & licence
How to contribute, how the suite is run, who this is built on, and the licence in full.
Contributing
Pull requests are welcome. A change is ready when three commands pass — they are the contract, and CI runs all three on every push.
composer test # pest
composer analyse # phpstan level 6
composer format # pint
If a change touches behaviour the Unsplash API Guidelines govern, it needs a test in
tests/Compliance named after the rule. That directory is what keeps the rules from
rotting, so a change that weakens it will be asked for a test rather than merged.
Running the suite
git clone https://github.com/gabrielesbaiz/unsplash-toolkit
cd unsplash-toolkit
composer install
composer test
The suite runs against SQLite in memory and never touches the network: every test drives either
Unsplash::fake() or Http::fake(). No API key is required to run it.
GitHub Actions runs the tests across PHP 8.2–8.4 and Laravel 11–12, plus PHPStan and Pint, on every push.
Credits
Written and maintained by Gabriele Sbaiz, with thanks to everyone who has contributed.
The 1.x line was forked from marksitko/laravel-unsplash by Mark Sitko. 2.0 is a rewrite, but the idea of a fluent Unsplash client for Laravel started there.
It stands on work this package does not contain: Laravel, and spatie/laravel-package-tools. Photos come from the photographers on Unsplash, who make them available for free.
Disclaimer
Licence
MIT, in full:
The MIT License (MIT)
Copyright (c) Gabriele Sbaiz <gabriele@sbaiz.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
The MIT licence's warranty disclaimer and limitation of liability apply in full, alongside the disclaimer above.