Install and wire up config
composer require cloudconvert/cloudconvert-php
The SDK talks to the CloudConvert API v2 (SDK ^2.x). Everything you do is a Job containing ordered Tasks (import/*, convert, export/*, etc.).
Add credentials to config/services.php (never read env() outside config — config caching will bite you):
'cloudconvert' => [
'api_key' => env('CLOUDFONVERT_API_KEY'), // CLOUDCONVERT_API_KEY
'sandbox' => env('CLOUDCONVERT_SANDBOX', false),
'webhook_secret' => env('CLOUDCONVERT_WEBHOOK_SECRET'),
],
Bind the client as a singleton in AppServiceProvider::register() so you don't rebuild Guzzle per resolution:
$this->app->singleton(\CloudConvert\CloudConvert::class, fn () => new \CloudConvert\CloudConvert([
'api_key' => config('services.cloudconvert.api_key'),
'sandbox' => (bool) config('services.cloudconvert.sandbox'),
]));
Where to look first
CloudConvert\Models\Task — the generic task object; the real documentation is the operations reference (every set() key, e.g. output_format, engine, engine_version, filename, pages).jobs(), tasks(), users(), webhooks(). Most days you only touch jobs() and tasks().sandbox => true and you can iterate without burning credits.First use case: sanity check, then a DOCX → PDF pipeline
// 1. Does auth + billing work at all?
$me = $cloudConvert->users()->me();
dump($me->getCredits()); // fail fast if this throws 401
// 2. Build a job
$job = (new Job())
->addTask(new Task('import/upload', 'upload'))
->addTask(
(new Task('convert', 'convert'))
->set('input', ['upload'])
->set('output_format', 'pdf')
)
->addTask(
(new Task('export/url', 'export'))
->set('input', ['convert'])
);
$cloudConvert->jobs()->create($job);
// 3. Push the bytes into the upload task
$cloudConvert->tasks()->upload(
$job->getTasks()->whereName('upload')[0],
fopen(storage_path('app/in.docx'), 'r'),
'in.docx'
);
// 4. Block until done (fine for a CLI/tinker smoke test only)
$cloudConvert->jobs()->wait($job);
$file = $job->getTasks()->whereName('export')[0]->getResult()->files[0];
dump($file->url, $file->filename); // temporary signed download URL
If that returns a URL you can curl, you have the whole mental model: create job → import → transform → export → consume URL.
| Task | Use when |
|---|---|
import/upload |
Local file / Laravel Storage stream. Two-step: create job, then tasks()->upload(). |
import/url |
The file is already publicly fetchable. ->set('url', $signedUrl). |
import/s3 (or signed URL into import/url) |
Private S3. CloudConvert cannot read your private bucket; generate a short-lived signed URL, or use the SDK's signed-URL resource for direct-to-CloudConvert uploads. |
jobs()->wait() polls, so never call it from a controller on a large file. Queue it, set $timeout generously, and let Horizon/Redis handle retries.
class ConvertDocumentToPdf implements ShouldQueue
{
public int $tries = 2;
public int $timeout = 300;
public function __construct(public int $documentId) {}
public function handle(CloudConvert $cloudConvert): void
{
$document = Document::findOrFail($this->documentId);
$name = 'doc-'.$document->id;
$job = (new Job())
->addTask(new Task('import/upload', 'upload'))
->addTask((new Task('convert', 'convert'))
->set('input', ['upload'])
->set('output_format', 'pdf')
->set('engine', 'office')
->set('filename', $name.'.pdf'))
->addTask((new Task('export/url', 'export'))
->set('input', ['convert']));
$cloudConvert->jobs()->create($job);
$uploadTask = $job->getTasks()->whereName('upload')[0];
$cloudConvert->tasks()->upload(
$uploadTask,
Storage::disk('local')->readStream($document->path),
basename($document->path)
);
$cloudConvert->jobs()->wait($job, 240);
if ($job->getStatus() !== 'finished') {
$errors = $job->getTasks()->whereStatus('error')->all();
throw new RuntimeException('CloudConvert job failed: '.json_encode($errors));
}
$file = $job->getTasks()->whereName('export')[0]->getResult()->files[0];
// Download NOW — result URLs are temporary.
$stream = fopen('php://temp', 'w+');
$client = new \GuzzleHttp\Client();
$client->get($file->url, ['sink' => $stream]);
rewind($stream);
Storage::disk('s3')->writeStream(
"converted/{$name}.pdf", $stream
);
$document->update(['pdf_path' => "converted/{$name}.pdf"]);
}
}
Dispatch it from a DocumentObserver/created hook and the rest of the app stays synchronous-looking.
For user-facing flows, don't hold a worker for 60s polling. Create the job with ->set('tag', "document:{$id}"), return immediately, and let CloudConvert call you back:
Route::post('/webhooks/cloudconvert', CloudConvertWebhookController::class)
->withoutMiddleware(['csrf']); // or exclude in bootstrap/app.php
public function __invoke(Request $request)
{
$ts = $request->header('CloudConvert-Timestamp');
$sig = $request->header('CloudConvert-Signature');
$expected = hash_hmac(
'sha256',
$ts.'.'.$request->getContent(),
config('services.cloudconvert.webhook_secret')
);
abort_unless($sig && hash_equals($expected, $sig), 403);
$payload = $request->json()->all();
if ($payload['event'] !== 'job.finished') {
return response()->noContent();
}
$job = app(CloudConvert::class)->jobs()->get($payload['job']['id']);
// Re-derive your entity from the tag you set at creation time
[$_, $documentId] = explode(':', $job->getTag());
DownloadConvertedDocument::dispatch((int) $documentId, $job->getId());
return response()->noContent();
}
Register the endpoint once with $cloudConvert->webhooks()->create(...), and make the handler idempotent (CloudConvert retries; store job_id uniquely).
input arrays reference names.$job->getTasks()->whereName('convert') rather than positional indexes — collection order is not a contract.->set('input', ['pdf-1', 'pdf-2']) for a merge task; fan-out for split + archive.class Pipeline
{
public static function convertTo(string $format, string $from = 'upload'): Job
{
return (new Job())
->addTask(new Task('import/upload', 'upload'))
->addTask((new Task('convert', 'convert'))
->set('input', [$from])
->set('output_format', $format))
->addTask((new Task('export/url', 'export'))
->set('input', ['convert']));
}
}
The client is Guzzle-based, so inject a MockHandler and assert on request bodies — no network, no credits, no sandbox dependency:
$mock = new \GuzzleHttp\Handler\MockHandler([
new \GuzzleHttp\Psr7\Response(200, [], json_encode(['data' => $fakeJob])),
]);
$cloudConvert = new CloudConvert([
'api_key' => 'test',
'guzzle_options' => ['handler' => \GuzzleHttp\HandlerStack::create($mock)],
]);
For the queued job, assert on the resulting Document state rather than the SDK calls where possible — the interesting logic is stream handling and error mapping, not the HTTP.
$file->url in your DB is a classic production bug.users()->me()->getCredits() on a health page or pre-flight check beats a mysterious 402 mid-job. Also handle "job finished but one task errored" — $job->getStatus() is the authority, and whereStatus('error')->all() gives you the per-task message.wait() blocks and polls. Give it an explicit timeout (the default waits indefinitely). For anything a user is staring at, use webhooks; for batch/back-office, queue + wait() is simpler and fine.import/url sources still need to be reachable by CloudConvert's servers. Private S3 objects need a signed URL — the most common "it works in tinker, fails in prod" cause.engine_version when output fidelity matters. CloudConvert upgrades engines (LibreOffice, ImageMagick, etc.); conversions that were pixel-perfect last quarter can shift. Set ->set('engine', 'office')->set('engine_version', '7.16.0') (or whatever the operations page lists) for anything contractual.set() is unvalidated magic. Typos like output_format → outputformat don't fail client-side; they surface as a 400 from the API or a silently wrong job. Keep option keys in the pipeline factory, not scattered through controllers.file_get_contents. Use Storage::readStream() for upload and Guzzle's 'sink' => $stream for download, then writeStream() to S3. A 200 MB PDF read into a PHP string will blow memory_limit inside a worker.POST, must not be behind auth, and must return 2xx quickly — dispatch the heavy lifting (including fetching the job and downloading the output) to a queue. Verify the HMAC signature exactly as shown; the signed payload is timestamp + "." + rawBody, so use getContent(), not json_encode($request->all()).->set('tag', "document:{$id}") (≤ 255 chars) is the cleanest way to correlate the webhook back to your model without a lookup table.DocumentConverter service so the SDK types (Job, Task) don't leak into controllers; that also gives you a single seam for the Guzzle mock in tests.cloudconvert-php v1 targets the old v1 API and its method surface (convert(), etc.) is unrelated — if you find a tutorial using $api->convert([...]), it's v1 and will not compile against ^2.x.How can I help you explore Laravel packages today?