fredbradley / smugmug
Integrate with the SmugMug API v2 using SaloonPHP
Requires
- php: ^8.5
- illuminate/cache: ^12.39||^13.0
- illuminate/collections: ^12.39||^13.0
- illuminate/contracts: ^12.39||^13.0
- illuminate/support: ^12.39||^13.0
- saloonphp/cache-plugin: ^3.3
- saloonphp/laravel-plugin: ^4.0||^5.0
- saloonphp/pagination-plugin: ^2.0
- saloonphp/saloon: ^4.4
- spatie/laravel-package-tools: ^1.19
Requires (Dev)
- larastan/larastan: ^3.13
- laravel/pint: ^1.20
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.0||^11.0
- pestphp/pest: ^4.0||^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A Laravel package for the SmugMug API v2, built with Saloon. It has a request, a resource method and a typed model for every endpoint SmugMug's Live API Browser lists: 322 of them. On top of those it covers OAuth 1.0a sign-in, uploads through SmugMug's uploader, and paging through any list.
The endpoints are grouped as the API groups them:
- people and their content:
users(),albums(),albumImages(),images(),nodes(),folders(),comments(),contacts(),trash() - marks and templates:
watermarks(),printmarks(),albumTemplates(),templates(),themes() - everything else:
pages(),downloads(),guides(),status(),catalog(),pricelists(),coupons(),orders(),sales(),designs(),account(),lookup()anduploads()
Every call is listed in ENDPOINTS.md.
use FredBradley\SmugMug\Facades\SmugMug; use FredBradley\SmugMug\Upload\Media; $me = SmugMug::users()->me(); // User foreach (SmugMug::users()->albums($me->NickName)->lazy() as $album) { // every album, fetched 100 at a time } $images = SmugMug::albums()->images('SJT3DX')->get(); // the first page, a Collection of AlbumImage $large = SmugMug::images()->sizeDetails('jPPKD2c-1')->ImageSizeLarge['Url']; SmugMug::nodes()->create($rootNodeId, ['Type' => 'Album', 'Name' => 'Sports Day 2026', 'Privacy' => 'Unlisted']); SmugMug::uploads()->upload('SJT3DX', Media::fromPath('/photos/finish-line.jpg'), ['Title' => 'Finish line']);
Requirements
- PHP 8.5
- Laravel 12.39 or higher, or Laravel 13
- A SmugMug API key and secret. Apply for one at smugmug.com/app/developer while signed in to SmugMug. The key alone reads public data. Anything private, and every change, needs a user to sign in with OAuth 1.0a.
Installation
composer require fredbradley/smugmug php artisan smugmug:install
The install command publishes config/smugmug.php. Set your application's key and secret in .env:
SMUGMUG_API_KEY=your-api-key SMUGMUG_API_SECRET=your-api-secret
That is enough to read public data. To act as one account (yours, for a site that shows or manages a single SmugMug account), add that account's access token. Signing in shows how to get one:
SMUGMUG_ACCESS_TOKEN=the-access-token SMUGMUG_ACCESS_TOKEN_SECRET=the-access-token-secret
| Setting | Env | Default | What it does |
|---|---|---|---|
api_key |
SMUGMUG_API_KEY |
none | Your application's API key, the OAuth consumer key. Required. |
api_secret |
SMUGMUG_API_SECRET |
none | The key's secret. Signs every OAuth request. |
access_token, access_token_secret |
SMUGMUG_ACCESS_TOKEN, SMUGMUG_ACCESS_TOKEN_SECRET |
none | The account the default connection acts as. Leave both empty to read public data only. |
callback_url |
SMUGMUG_CALLBACK_URL |
oob |
Where SmugMug sends a user back after they approve your application. oob has SmugMug show them a six-digit code instead. |
connections |
[] |
Other applications or accounts, for SmugMug::connection('name'). |
|
timeouts.connect, timeouts.request, timeouts.upload |
SMUGMUG_CONNECT_TIMEOUT, SMUGMUG_REQUEST_TIMEOUT, SMUGMUG_UPLOAD_TIMEOUT |
10, 30, 300 | Seconds. |
retries.times, retries.interval_ms |
SMUGMUG_RETRIES |
off, 500 | How many times to retry a GET that failed for a reason on SmugMug's side. |
cache.enabled, cache.store, cache.seconds |
SMUGMUG_CACHE, SMUGMUG_CACHE_STORE |
off, default store, 3600 | Caches GET responses. |
pagination.per_page |
100 | The page size lazy() and all() fetch with. |
Signing in
SmugMug uses OAuth 1.0a. The package signs every request with HMAC-SHA1 (RFC 5849), in the Authorization header,
which is also the only form SmugMug's uploader accepts. Sign-in has three steps:
SmugMug::oauth()->requestToken()gets a temporary request token, signed by your application. It lasts five minutes and can be used once. Keep it in the session.- Send the user to
SmugMug::oauth()->authorizationUrl($requestToken, $access, $permissions). SmugMug asks them to approve your application, then sends them back to your callback URL withoauth_tokenandoauth_verifier(or, foroob, shows them a six-digit code). SmugMug::oauth()->accessToken($requestToken, $verifier)trades the request token and verifier for the user's access token and secret. SmugMug's access tokens don't expire. A user can revoke one from their SmugMug account settings, after which requests answer 401.
What a token may do is fixed when the user approves it:
Access |
What the application sees |
|---|---|
Access::Public |
Only what anyone may see. SmugMug's default. |
Access::Full |
Everything the user may see: private and unlisted albums, account details, orders. |
Permissions |
What the application may do |
|---|---|
Permissions::Read |
GET only. SmugMug's default. |
Permissions::Add |
Read, plus create: new albums, folders, nodes and comments, and uploads. |
Permissions::Modify |
Everything: PATCH and DELETE, moves and sorts, bulk image edits, replacing images. |
In a web application
use FredBradley\SmugMug\Auth\RequestToken; use FredBradley\SmugMug\Enums\Access; use FredBradley\SmugMug\Enums\Permissions; use FredBradley\SmugMug\Facades\SmugMug; use Illuminate\Http\Request; Route::get('/smugmug/connect', function () { $requestToken = SmugMug::oauth()->requestToken(route('smugmug.callback')); session(['smugmug.request_token' => $requestToken->toArray()]); return redirect(SmugMug::oauth()->authorizationUrl($requestToken, Access::Full, Permissions::Modify)); })->middleware('auth'); Route::get('/smugmug/callback', function (Request $request) { $requestToken = RequestToken::fromArray(session()->pull('smugmug.request_token')); abort_unless(hash_equals($requestToken->token, (string) $request->query('oauth_token')), 403); $accessToken = SmugMug::oauth()->accessToken($requestToken, (string) $request->query('oauth_verifier')); $request->user()->update([ 'smugmug_token' => $accessToken->token, // cast both as 'encrypted' on the model 'smugmug_token_secret' => $accessToken->secret, ]); return redirect('/')->with('status', 'SmugMug connected.'); })->middleware('auth')->name('smugmug.callback');
Then act as that user wherever you need to:
$smugmug = SmugMug::withToken($user->smugmug_token, $user->smugmug_token_secret); $smugmug->users()->me(); $smugmug->uploads()->upload($albumKey, Media::fromPath($path));
withToken() returns a new connector with every resource on it. Store tokens as you would passwords: encrypted, and
never logged. A connector, an OAuth1Authenticator and an AccessToken keep their secrets out of dump() and
print_r(), and a connector refuses to be serialised, so it can't leak into a queued job's payload. In a queued job,
pass the user's id and build the connector in handle().
For one account, without a web flow
For a site that only ever uses your own account, get its token once with oob and put it in .env:
// php artisan tinker $oauth = SmugMug::oauth(); $requestToken = $oauth->requestToken('oob'); echo $oauth->authorizationUrl($requestToken, Access::Full, Permissions::Modify); // open the URL, approve, and copy the six-digit code $token = $oauth->accessToken($requestToken, '123456'); echo $token->token, ' ', $token->secret; // SMUGMUG_ACCESS_TOKEN and SMUGMUG_ACCESS_TOKEN_SECRET
authorizationUrl() also takes SmugMug's other authorize options: ['allowThirdPartyLogin' => 1, 'showSignUpButton' => false, 'username' => 'cmac', 'viewportScale' => 0.5].
Usage
Two ways to call SmugMug
Resources give a method for each endpoint, take keys as arguments, and return models. When SmugMug answers with
an error they throw a SmugMugApiException:
$album = SmugMug::albums()->get('SJT3DX'); // Album $album = SmugMug::albums()->find('nope'); // null for a 404 $album = SmugMug::albums()->update('SJT3DX', ['Privacy' => 'Unlisted']); SmugMug::albums()->delete('SJT3DX'); // true
Requests are plain Saloon, for when you want the response itself:
use FredBradley\SmugMug\Requests\Album\GetAlbumRequest; $response = SmugMug::send(new GetAlbumRequest('SJT3DX', ['_expand' => 'HighlightImage'])); $response->status(); $response->json(); $response->dto(); // the same Album the resource gives; throws SmugMugApiException for an error
SmugMug::follow($uri) fetches any URI SmugMug gives you, such as $album->uri('AlbumImages'), as its model.
SmugMug::get(), post(), patch(), put() and delete() send any path on api.smugmug.com and return the
response. Every resource, request and model is listed in ENDPOINTS.md.
Users
$me = SmugMug::users()->me(); // the user the token belongs to $user = SmugMug::users()->get('cmac'); $users = SmugMug::users()->many(['cmac', 'onethumb'])->get(); $profile = SmugMug::users()->profile('cmac'); // UserProfile: BioText, Facebook, Instagram and so on $root = SmugMug::users()->rootNode('cmac'); // Node: the top of their site $features = SmugMug::users()->features('cmac'); // Features: what their plan allows SmugMug::users()->updateProfile('cmac', ['BioText' => 'School photography since 1865.']); SmugMug::users()->featuredAlbums('cmac')->get(); SmugMug::users()->addFeaturedAlbum('cmac', ['AlbumUri' => '/api/v2/album/SJT3DX']);
Albums and their images
$album = SmugMug::albums()->get('SJT3DX'); $album->Name; // "Sports Day" $album->ImageCount; $album->LastUpdated; // CarbonImmutable $album->WebUri; // its page on SmugMug $images = SmugMug::albums()->images('SJT3DX')->all(); // every AlbumImage $popular = SmugMug::albums()->popularMedia('SJT3DX', ['MediaType' => 'Images'])->get(); SmugMug::albums()->update('SJT3DX', [ 'Name' => 'Sports Day 2026', 'Privacy' => 'Unlisted', // Public, Unlisted or Private 'SecurityType' => 'Password', // None, Password or GrantAccess 'Password' => 'relay', 'AllowDownloads' => false, 'LargestSize' => 'X2Large', 'SortMethod' => 'Date Taken', ]); SmugMug::albumImages()->update('SJT3DX', 'jPPKD2c-1', ['Caption' => 'Year 9 relay', 'KeywordArray' => ['relay', 'year 9']]); SmugMug::albumImages()->delete('SJT3DX', 'jPPKD2c-1'); // removes it from this album
Folders, albums and pages: nodes
New SmugMug organises a site as nodes: folders, albums and pages, each with a node id.
$root = SmugMug::users()->rootNode('cmac'); $children = SmugMug::nodes()->children($root->NodeID, ['Type' => 'Album', 'SortMethod' => 'DateModified'])->get(); $folder = SmugMug::nodes()->create($root->NodeID, ['Type' => 'Folder', 'Name' => 'Archive', 'Privacy' => 'Private']); $album = SmugMug::nodes()->create($folder->NodeID, ['Type' => 'Album', 'Name' => '2025-26', 'UrlName' => '2025-26']); SmugMug::nodes()->move($folder->NodeID, ['MoveUris' => ['/api/v2/node/abc123', '/api/v2/node/def456']]); SmugMug::nodes()->parents($album->NodeID)->get(); // the breadcrumb, nearest first SmugMug::nodes()->update($album->NodeID, ['HighlightImageUri' => '/api/v2/image/jPPKD2c-1']);
A node of type Album links its album: SmugMug::follow($node->uri('Album')). The older folder API, by path, is
folders(): folders()->get('cmac', 'Travel/2024'), folders()->albums('cmac', ''), folders()->createAlbum() and
so on. SmugMug recommends nodes.
lookup() finds a folder, album or page by its address:
lookup()->webUri(['WebUri' => 'https://cmac.smugmug.com/Travel/2024']) or
lookup()->urlPath(['nickname' => 'cmac', 'urlpath' => '/Travel/2024']). Each returns the model for whatever it
found.
Images, sizes and downloads
use FredBradley\SmugMug\Enums\Size; $image = SmugMug::images()->get('jPPKD2c-1'); $image->FileName; $image->OriginalWidth; $image->IsVideo; $image->Latitude; $sizes = SmugMug::images()->sizes('jPPKD2c-1'); // ImageSizes: a URL per size $sizes->LargeImageUrl; $sizes->ImageUrl4K; // SmugMug's "4KImageUrl" $details = SmugMug::images()->sizeDetails('jPPKD2c-1'); // each size's URL, width, height and file size $thumb = SmugMug::images()->size('jPPKD2c-1', Size::Thumb); // ImageSize: Url, Width, Height, Size, MD5 $largest = SmugMug::images()->largest('jPPKD2c-1'); // the largest the credentials may see $fitted = SmugMug::images()->customSize('jPPKD2c-1', ['width' => 1200, 'height' => 630, 'fill' => true]); $exif = SmugMug::images()->metadata('jPPKD2c-1'); // ImageMetadata: Make, Model, Lens, ISO and so on $original = SmugMug::images()->download('jPPKD2c-1')->Url;
Search
SmugMug::images()->search([ 'Scope' => '/api/v2/user/cmac', // required: a user, node or album URI 'Text' => 'relay', 'SortMethod' => 'DateTaken', 'DateTakenStart' => '2026-06-01T00:00:00Z', ])->get(); SmugMug::albums()->search(['Scope' => '/api/v2/user/cmac', 'Text' => 'sports'])->get(); SmugMug::nodes()->search(['Scope' => '/api/v2/node/'.$root->NodeID, 'Text' => 'archive'])->get(); SmugMug::users()->searchImages('cmac', ['q' => 'relay'])->get();
Bulk edits
Actions return SmugMug's Response as an array. Those that take Async: true run in the background and return the
URI of their status, which status() reads.
SmugMug::albums()->moveImages('TARGET', ['MoveUris' => ['/api/v2/album/SRC/image/a-0', '/api/v2/album/SRC/image/b-0']]); SmugMug::albums()->collectImages('TARGET', ['CollectUris' => [...]]); // copy into another album too SmugMug::albums()->sortImages('SJT3DX', ['MoveUris' => [...], 'MoveLocation' => 'Before', 'Uri' => '/api/v2/album/SJT3DX/image/c-0']); SmugMug::albums()->deleteImages('SJT3DX', ['DeleteUris' => [...], 'Async' => true]); SmugMug::images()->addKeywords(['ImageUris' => [...], 'Keywords' => 'sports day; 2026']); SmugMug::images()->rotateMany(['RotateUris' => [...], 'Degrees' => 90, 'Flip' => 'None']); SmugMug::images()->watermarkMany(['WatermarkUris' => [...], 'WatermarkUri' => '/api/v2/watermark/780235']); SmugMug::nodes()->updateAlbums(['NodeUris' => [...], 'Privacy' => 'Private', 'AllowDownloads' => false]);
SmugMug takes some of these lists as one comma-separated string and some as arrays. Pass a PHP list either way; the package writes it the way each endpoint wants it.
Uploads
use FredBradley\SmugMug\Upload\Media; $result = SmugMug::uploads()->upload('SJT3DX', Media::fromPath('/photos/finish.jpg'), [ 'Title' => 'Finish line', 'Caption' => 'Year 9 relay', 'Keywords' => ['sports day', 'relay'], 'Hidden' => false, ]); $result->ImageUri; // /api/v2/image/Ab12Cd3-0 $result->imageKey(); // Ab12Cd3-0 $result->URL; // its page on SmugMug SmugMug::uploads()->replace('SJT3DX', $result->ImageUri, Media::fromPath('/photos/finish-edited.jpg')); SmugMug::uploads()->upload('SJT3DX', Media::fromContents($request->file('photo')->get(), 'photo.jpg'));
How an upload works:
- Where it goes.
upload()andreplace()use SmugMug's uploader,https://upload.smugmug.com/. - What it sends. The file goes as the body, streamed from disk. Its MD5 goes in
Content-MD5, so SmugMug rejects a truncated upload, and its details go inX-Smug-*headers. - What you need. The uploader takes OAuth only, so an upload needs an access token with Add permission (Modify to replace). Without one the package refuses before sending.
- Through the API instead.
toAlbum()andreplaceMedia()send the same file as multipart form data (album!imagesandimage!replace). - Large files.
SMUGMUG_UPLOAD_TIMEOUT(300 seconds by default) applies to uploads only.
Watermarks, templates and the rest
SmugMug::watermarks()->forUser('cmac')->get(); SmugMug::watermarks()->create(['Name' => 'School crest', 'ImageID' => 123456, 'Pinned' => 'BottomRight', 'Dissolve' => 40]); SmugMug::albumTemplates()->create('cmac', ['Name' => 'Events', 'Privacy' => 'Unlisted', 'Watermark' => true]); SmugMug::comments()->lookup(['type' => 'Album', 'typeid' => 1234, 'status' => 'Pending'])->get(); SmugMug::contacts()->create('cmac', ['PrimaryEmail' => 'parent@example.com', 'Name' => 'A Parent']); SmugMug::albums()->grant('SJT3DX', ['Email' => 'parent@example.com']); SmugMug::trash()->albums('cmac')->get(); SmugMug::trash()->recoverAlbum($nodeId, ['IncludeChildren' => true]);
There are also selling endpoints (catalog(), pricelists(), coupons(), orders(), sales()), site designs
(designs()), and account endpoints (account(): API keys, plans, subscriptions, invoices).
ENDPOINTS.md lists each call with its parameters.
Lists and paging
A method for a list returns a Listing and sends nothing until you ask:
| Call | Returns |
|---|---|
->get() |
One page, as a Collection: the start and count in the options, or SmugMug's default page. |
->page(3, 50) |
Page 3 of 50 items. |
->lazy() |
Every item, as a LazyCollection, fetching a page of smugmug.pagination.per_page at a time as you iterate. lazy(500) sets the page size. |
->all() |
Every item, fetched at once. |
->first() |
The first item, or null. |
->total() |
How many there are (SmugMug's Pages.Total). |
->paginator() |
The SmugMugPaginator, for each page's response. |
foreach ($listing as $item) is lazy(). Paging uses SmugMug's start (from 1) and count. It stops when SmugMug
gives no NextPage, a page is short or empty, or the total is reached. A request from SmugMug::send() pages the same
way with SmugMug::paginate($request).
Options: expansions, filters and an endpoint's parameters
Every call takes options last, as an array or an Options:
use FredBradley\SmugMug\Query\Options; SmugMug::albums()->images('SJT3DX', Options::make() ->filter('ImageKey', 'FileName', 'Caption') // _filter: only these fields ->filterUri('ImageSizes') // _filteruri: only these Uris ->expand('ImageSizes') // _expand: include related objects ->count(50)->start(51) // paging ->with('MediaType', 'Images') // the endpoint's own parameters )->get(); SmugMug::albums()->expand('HighlightImage')->get('SJT3DX'); // the same options for every call on a resource
Options also has:
shortUris(), for_shorturisverbosity(1), for_verbosityconfig([...]), for_config, SmugMug's advanced form. For example,['expand' => ['ChildNodes' => ['args' => ['count' => 25]]]]fetches 25 children with each node.
Lists are sent comma-separated and booleans as true and false. Expanded objects arrive in the response's
Expansions, keyed by URI. Read them from a request's response with FredBradley\SmugMug\Support\Payload::expansions($response).
Models
Every object is a readonly model with SmugMug's own PascalCase property names and types:
$album->AlbumKey,$album->LastUpdated(aCarbonImmutable),$image->KeywordArray.- Names SmugMug starts with a digit are moved round:
ImageSizes::$ImageUrl4Kfor4KImageUrl,$VideoUrl1920for1920VideoUrl.toArray()writes SmugMug's names back. - SmugMug adds fields over time and shows some only to the owner, so a model keeps the fields it has no property for
in
$extra. uri('AlbumImages')reads a related endpoint from the object'sUris, andSmugMug::follow()fetches it.
toArray() and json_encode() leave out a model's secrets: Album::$Password, $DownloadPassword,
User::$ViewPassword and ApiKey::$APIKeySecret. That way a model that is logged, cached or returned from a route
doesn't carry them. toArray(withHidden: true) includes them.
Writing
create(), update() and every POST or PATCH take SmugMug's field names as an array, or a model. A PATCH changes only
the fields it carries. When given a model, the package leaves out Uri, Uris, WebUri, UriDescription and
ResponseLevel, which SmugMug sends but never takes back. SmugMug answers a write with the object as stored, which
the method returns as its model.
Errors
SmugMug wraps every answer in {"Response", "Code", "Message"}. A resource method throws a SmugMugApiException for
an error status, and for a 200 whose envelope Code says the request failed. A request's dto(), $response->throw()
and the paginator do the same. errorMessage() gives SmugMug's Message, and getResponse() the response. The
exception's message never includes the query string, which may carry the API key.
| SmugMug answered | Exception |
|---|---|
| 400, 405, 406, 415, 422 | InvalidRequestException |
| 401: bad credentials, or a token without the Access or Permissions needed | UnauthorizedException |
| 402: the subscription has lapsed | PaymentRequiredException |
| 403 | ForbiddenException |
404 (find() returns null instead) |
NotFoundException |
| 409: the name or URL is taken | ConflictException |
| 429 | RateLimitedException, whose retryAfter() gives the seconds to wait |
| 408, 5xx, or a success that isn't JSON | ServerErrorException |
a 200 whose envelope or uploader stat says it failed |
FailedException |
Rate limits
SmugMug limits requests per user of each application, and per application for requests without a token. Every
response says where the window stands, and SmugMug::rateLimit($response) reads it:
remaining: requests left in the window.resetsAt: when the window ends.retryAfter: seconds to wait, on a 429.
SMUGMUG_RETRIES retries a GET that got a 429 with exponential backoff. Writes are never retried.
Several accounts or applications
Add them under connections in config/smugmug.php, with the same keys as the default, and reach them with
SmugMug::connection('name'). For a user of your application, use SmugMug::withToken($token, $secret).
SmugMug::withoutToken() reads public data.
Caching, timeouts and retries
Caching. SMUGMUG_CACHE=true caches GET responses for smugmug.cache.seconds in SMUGMUG_CACHE_STORE (the
default cache store when unset).
- There is one entry per application, token and query, so one user never reads what another fetched.
- An answer whose envelope says it failed is not kept.
- What is cached is the objects as the credentials saw them, private ones included, so use a store the application trusts.
Timeouts. SMUGMUG_CONNECT_TIMEOUT, SMUGMUG_REQUEST_TIMEOUT and SMUGMUG_UPLOAD_TIMEOUT are in seconds.
Retries. SMUGMUG_RETRIES retries a GET that failed with a connection error, 408, 429 or 5xx, with exponential
backoff from smugmug.retries.interval_ms.
Testing your application
The package is plain Saloon, so fake SmugMug with a MockClient:
use FredBradley\SmugMug\Requests\User\GetAuthUserRequest; use Saloon\Http\Faking\MockClient; use Saloon\Http\Faking\MockResponse; MockClient::global([ GetAuthUserRequest::class => MockResponse::make([ 'Response' => ['Locator' => 'User', 'LocatorType' => 'Object', 'User' => ['NickName' => 'cmac']], 'Code' => 200, 'Message' => 'Ok', ]), ]); expect(SmugMug::users()->me()->NickName)->toBe('cmac');
Testing
composer test
The suite fakes SmugMug with Saloon's MockClient, so nothing reaches the real API. It sends every request class
and every resource method, and checks each one's method, path, body and model. It also checks the OAuth signature of
each request, against the RFC 5849 example as well as SmugMug's own requests. composer analyse runs PHPStan, and
composer format runs Pint.
Contributing
Pull requests are welcome. Run composer format, composer analyse and composer test before opening one.
Security
If you discover a security issue, email code@fredbradley.co.uk rather than opening an issue.
Credits
Licence
MIT.