spatie/dropbox-api
A minimal PHP client for Dropbox API v2, providing just the methods needed for the flysystem-dropbox adapter. Create folders, list contents, and generate temporary links with a simple authorization token.
This is a thin Guzzle wrapper around Dropbox API v2 — not a Laravel package. There is no service provider, no config file, no facade, and no HTTP-client integration with Illuminate\Support\Facades\Http. Plan for ~15 minutes to wire it in yourself.
1. Install and get a token
composer require spatie/dropbox-api
Generate an access token in the Dropbox App Console and put it in .env:
DROPBOX_TOKEN=sl.xxxxxxxxxxxxxxxxxxxx
2. Bind the client once
// config/services.php
'dropbox' => ['token' => env('DROPBOX_TOKEN')],
// app/Providers/AppServiceProvider.php — register()
$this->app->singleton(\Spatie\Dropbox\Client::class, fn () => new \Spatie\Dropbox\Client(
config('services.dropbox.token')
));
3. First use case: ship a generated artifact and hand back a link
The most common day-one need is "put this export/file in Dropbox and give me a URL." That's two calls:
$client = app(\Spatie\Dropbox\Client::class);
$client->upload('/exports/invoices-2026-01.csv', $csv, 'overwrite');
$url = $client->getTemporaryLink('/exports/invoices-2026-01.csv')['link'];
Where to look first: the README's "Dropbox Endpoints" section explicitly tells you to read src/Client.php — do that. The public method list is the documentation for this package, and it's short. Methods you'll actually reach for: upload, download, createFolder, listFolder / listFolderContinue, move, copy, delete, getMetadata, getTemporaryLink, listSharedLinks, createSharedLinkWithSettings, revokeToken, getAccountInfo, plus the chunked-upload session methods.
Know when to not use it directly: this package is the transport underneath spatie/flysystem-dropbox. If what you want is a Storage::disk('dropbox') that behaves like any other Laravel disk, install the Flysystem adapter and reach for Client only for the operations Flysystem doesn't model.
TokenProvider (the pattern you'll need in production)Since Dropbox moved to short-lived access tokens, a single static DROPBOX_TOKEN env var is only viable for a single-tenant app. For multi-tenant, implement the interface:
use Spatie\Dropbox\TokenProvider;
final class AccountDropboxToken implements TokenProvider
{
public function __construct(private DropboxAccount $account) {}
public function getToken(): string
{
return Cache::remember(
"dropbox:access:{$this->account->id}",
now()->addMinutes(50), // tokens live ~4h; refresh early
fn () => $this->account->refreshAccessToken(), // oauth2-client, refresh_token grant
);
}
}
$client = new Client(new AccountDropboxToken($account));
Because the client resolves the token through the provider on each request, expiration is handled at the boundary. App-only auth (new Client([$appKey, $appSecret])) exists for app-folder-scoped work and no-auth construction works for public endpoints — both landed in 1.14.0.
Uploads are network-bound and flaky. Never do them inline in a request:
final class UploadExport implements ShouldQueue
{
public int $tries = 5;
public array $backoff = [10, 60, 300];
public function __construct(public int $exportId) {}
public function handle(Client $client): void
{
$export = Export::findOrFail($this->exportId);
// 'add' | 'overwrite' | 'update' (update requires a rev)
$client->upload("/exports/{$export->filename}", $export->contents(), 'overwrite');
}
public function failed(Throwable $e): void { /* mark export as failed */ }
}
upload() accepts a string body and, since 1.20.2, a stream resource — so you can pass an open file handle instead of reading a 200 MB file into memory.
uploadChunked()Single-call upload caps out at 150 MB. For anything approaching that, use the chunked path:
$stream = fopen(storage_path("app/{$file}"), 'r');
$client->uploadChunked("/backups/{$file}", $stream);
For resumable/retryable behaviour across job attempts, drive the session manually with uploadSessionStart() → uploadSessionAppend() → uploadSessionFinish() and persist the session_id between attempts. Note 1.25.0 fixed an infinite loop in uploadChunked() when the transport didn't read past stream end — pin to ^1.25 if you're chunking streams.
Dropbox returns a conflict if the folder exists, so treat conflict as success:
try {
$client->createFolder($path);
} catch (\Spatie\Dropbox\Exceptions\BadRequest $e) {
// already exists — fine
}
getTemporaryLink() returns a URL valid for ~4 hours. Generate it on demand, never store it. If you need a durable URL that survives, use createSharedLinkWithSettings() (or listSharedLinks() to reuse an existing one), and revoke via the Dropbox UI / API when the relationship ends.
rpcEndpointRequest / contentEndpointRequestThe package is deliberately minimal; anything not wrapped is one call away. This is the intended extension point, not a hack:
// search
$client->rpcEndpointRequest('search', ['path' => '', 'query' => 'bat cave']);
// thumbnail batch — note the subdomain prefix
$client->rpcEndpointRequest('content::files/get_thumbnail_batch', $parameters);
The subdomain:: prefix is the documented way to hit the content host for RPC-style endpoints that live there. Anything you can express as a JSON body goes through rpcEndpointRequest; anything with a binary body goes through contentEndpointRequest.
listFolder() returns entries, cursor, and hasMore. Loop with listFolderContinue($cursor) until hasMore is false — Dropbox caps page size, so an un-looped listFolder silently truncates large directories.
Http::fake() will not intercept this client — it constructs its own Guzzle stack. Two viable approaches:
// A) mock the concrete client
$client = Mockery::mock(Client::class);
$client->shouldReceive('upload')->once()->with('/exports/x.csv', Mockery::any(), 'overwrite');
$this->app->instance(Client::class, $client);
// B) better long-term: define your own DropboxStorage interface,
// implement it with Spatie's Client, and mock the interface.
Option B costs 20 lines and decouples your domain from a third-party concrete class you can't otherwise intercept.
429 with a Retry-After header. Laravel's retry() helper doesn't read that header. Catch the exception in your job and $this->release($seconds) accordingly, or you'll hammer the API and get throttled harder.upload() and move() throw BadRequest on collision with messages containing things like to/conflict/file/... Since 1.3.0 the exception carries the Dropbox error code — branch on that, not on string matching, when you can. Both methods accept an autorename flag (1.18.0 / 1.19.0): $client->move($from, $to, autorename: true) yields somefile (1).txt. Use it when a duplicate is acceptable; use overwrite mode when it isn't.scoped() or instantiate it per job, otherwise you'll leak one tenant's token into another's request under Octane./. The client's path normalizer also permits id:, rev:, and ns: prefixed references (1.6.5) — useful for referring to a file that has been renamed underneath you.getTemporaryLink needs the file to exist and to be a file, not a folder — folders get no temp link; use a shared link on the folder instead.BadRequests.uploadChunked() stream handling was broken before 1.25.0. If your composer constraint floats down to ^1.20, you'll hit both bugs. Pin to a recent minor.revokeToken() when a user disconnects, but be aware it kills every token for that app/user pair — not just the one you hold. Revoke before dropping the record, or you'll orphan a live grant.listFolderContinue / files/list_folder/longpoll via rpcEndpointRequest to fetch changes.listFolder does not recurse. Set recursive => true in the arguments array, or you'll get one level and wonder where the files went.rpcEndpointRequest as a first-class API, and wrap it in your own small repository class so your codebase has one place to change when you need a new endpoint.How can I help you explore Laravel packages today?