revolution/laravel-google-photos
Laravel wrapper for the Google Photos Library API. Browse, search, upload, and manage photos and albums from your Laravel app using a simple, expressive client. Includes a service provider, publishable config, OAuth credential handling, and a familiar facade for quick setup in any project.
Install and publish
composer require revolution/laravel-google-photos
php artisan vendor:publish --tag=google-photos-config
The published config/google-photos.php is the first file to open — it holds client_id, client_secret, and refresh_token. Everything else in the package is a thin wrapper over the Google Photos Library API v1, so the config plus src/ (and src/Facades/) is the whole surface area.
Get credentials before writing code
google/apiclient, or the OAuth 2.0 Playground with scope https://www.googleapis.com/auth/photoslibrary.appendonly (add photoslibrary.readonly only if your app has been approved for restricted scopes — see Gotchas).GOOGLE_PHOTOS_CLIENT_ID=...
GOOGLE_PHOTOS_CLIENT_SECRET=...
GOOGLE_PHOTOS_REFRESH_TOKEN=...
First use case: append a directory of images to a known album
This is what the package is actually good at. Not browsing a user's library — writing to an album your app owns.
use Revolution\Google\Photos\Facades\Photos; // confirm exact alias in src/Facades
$album = Photos::albums()->create(['title' => 'Product shots 2024']);
// store $album->id in your DB — never re-create, never hardcode
Then confirm the facade resolves and the token refreshes with php artisan tinker:
Photos::albums()->listAlbums(['pageSize' => 10]);
If that returns albums, the client, token, and scopes are all correct and you can build from there.
Where to look first
config/google-photos.php — token strategy.src/Facades/*.php — the real method names; the README lags the code in archived packages.Treat the facade as a raw API client, not a repository
The package does almost no abstraction. Design your own thin service around it so the rest of the app never imports Revolution\...:
final class PhotoAlbum
{
public function __construct(private string $albumId) {}
public function upload(string $path): string
{
$token = Photos::uploads()->bytes(
file_get_contents($path),
['fileName' => basename($path)]
)->uploadToken;
$item = Photos::mediaItems()->batchCreate([
'albumId' => $this->albumId,
'newMediaItems' => [[
'simpleMediaItem' => ['uploadToken' => $token, 'fileName' => basename($path)],
]],
]);
return $item->newMediaItemResults[0]->mediaItem->id;
}
}
Two-step upload (bytes → uploadToken → batchCreate) is the core primitive. Everything else is bookkeeping.
Store the IDs, not the URLs
Persist album_id, media_item_id, and optionally productUrl. Never persist baseUrl as a permanent link — it expires and needs a sizing suffix (=w2048-h1024) appended at render time. If you need durable images, download to S3 and serve your own copy.
Model the token per-tenant when it isn't a single account
The env-based refresh token is fine for a single-account marketing site. For multi-user, add google_photos_refresh_token (encrypted cast) to users and call the token setter before each operation:
protected $casts = ['google_photos_refresh_token' => 'encrypted'];
Photos::setToken($user->google_photos_refresh_token);
Wrap that in a job or a resolvePhotosFor($user) helper — mixing global config and per-user tokens in one request is the #1 source of "wrong user's album" bugs.
Push every write into a queued job
final class SyncAlbumPhotos implements ShouldQueue
{
use InteractsWithQueue, Queueable;
public int $tries = 3;
public array $backoff = [10, 60, 300];
public function handle(): void { /* upload + batchCreate */ }
public function middleware(): array
{
return [new RateLimited('google-photos')];
}
}
batchCreate accepts up to 50 items per call. Chunk with collect($paths)->chunk(50) rather than looping one item per request — it's the difference between 10 requests and 500.
Filter and paginate on the read side
$result = Photos::mediaItems()->search([
'albumId' => $albumId,
'pageSize' => 100,
'filters' => [
'mediaTypeFilter' => ['mediaTypes' => ['PHOTO']],
'dateFilter' => ['ranges' => [[
'startDate' => ['year' => 2024, 'month' => 1, 'day' => 1],
]]],
],
]);
pageSize maxes at 100 for search and 50 for albums, and there's almost always a nextPageToken. Write one paginator helper and reuse it, or you'll silently process only the first page — a very common bug that looks like "the sync worked, but only for 50 photos."
Testing
The package sits on top of an HTTP client, so Http::fake() usually works if it uses Laravel's client; otherwise stub the facade:
Photos::shouldReceive('mediaItems->batchCreate')->once()->andReturn($fake);
Better: inject your own PhotoAlbum service and fake that. Keeps the test independent of which package version you're pinned to.
Extension points
$this->app->singleton(...)) to swap in a wrapped version with logging or retry.Photos::swap($fake) works for integration tests.The package is archived — and so is the API surface it targets.
The biggest risk isn't the code, it's the platform. Google restricted the broader photoslibrary.readonly scope on 31 March 2025; third-party apps that read a user's whole library need approval, and new integrations are steered to the Photos Picker API instead. Apps that only upload to their own albums are in a much better place. Before you build anything read-heavy, validate against current Google policy — no package upgrade will fix a scope you can't get.
Pin the version and check Laravel compatibility.
Zero dependents plus archived status means nobody is filing the Laravel 11/12 issue for you. Inspect composer.json for illuminate/support constraints; if it caps below your framework version, either fork with a broadened constraint or vendor the class files into app/. Don't expect a patch release.
Token expiry masquerading as an auth bug.
If the OAuth consent screen is in "Testing" mode, refresh tokens expire after 7 days. Every week your sync dies and you blame the package. Publish the consent screen (or use a Workspace-internal app) or you'll be re-authing on a cron.
Upload tokens are single-use and short-lived.
A batchCreate fails → your retry logic re-sends the same uploadToken → it fails again with a confusing 400. Retry the upload, not just the create. Treat uploadToken as a value that never leaves the function that created it.
Large files need resumable upload.
Simple byte uploads cap out around 50 MB per request. Videos and RAW files need the resumable endpoint, which this wrapper likely doesn't implement — drop to google/apiclient or raw Guzzle for those and keep the package for stills.
429s are normal, not exceptional.
Quotas are per-project and low (a few thousand requests/day, tens per minute, historically). RateLimited middleware plus backoff is not optional for a bulk import. Log the response body on failure — Google puts the actual reason (RESOURCE_EXHAUSTED, INVALID_ARGUMENT) in error.message, not in the exception message.
Config caching.
php artisan config:cache freezes env() at the config layer. Changing GOOGLE_PHOTOS_REFRESH_TOKEN in .env and seeing no effect means you forgot config:clear. Standard Laravel trap, hits this package constantly because credentials are the whole config file.
Don't log the payloads.
Upload bodies and uploadToken values are effectively credentials in flight. Scrub them in your HTTP client wrapper before anything reaches the log channel.
Album ownership matters more than you think.
Media items your app created are reliably the ones you can manage later; items a user added manually are a different story. Design around "this app owns this album" rather than "this app manages the user's library" — it's both a permissions and a UX boundary, and it's the assumption the surviving API behaviour rewards.
When to walk away.
For a one-off internal sync of a few hundred images to a fixed album, this package saves an afternoon and is fine. For anything user-facing, multi-tenant, or long-lived, spend the extra day on google/apiclient or direct Guzzle against the current API — you'll need the flexibility anyway, and an archived wrapper is one Laravel upgrade away from a rewrite.
How can I help you explore Laravel packages today?