deeplcom/deepl-php
Official PHP client for the DeepL translation API. Send text and documents for high-quality translation from PHP 7.3+ applications, with full API support and simple Composer installation.
1. Install and key up
composer require deeplcom/deepl-php
Get a key from your DeepL account (the Free tier gives 500k characters/month and the key ends in :fx — the library routes Free vs Pro hosts automatically). Put it in .env, never in a committed file:
DEEPL_AUTH_KEY=f63c02c5-f056-...
2. Register a singleton — don't new DeepLClient at call sites. config/services.php:
'deepl' => [
'key' => env('DEEPL_AUTH_KEY'),
'options' => [
'timeout' => 30, // see DeepLClientOptions for exact setters
],
],
app/Providers/AppServiceProvider.php:
use DeepL\DeepLClient;
use DeepL\DeepLClientOptions;
$this->app->singleton(DeepLClient::class, fn () => new DeepLClient(
config('services.deepl.key'),
new DeepLClientOptions(config('services.deepl.options')),
));
3. Where to look first
src/DeepLClient.php — the whole public surface. Read the method signatures once; almost everything is translateText, rephraseText, translateDocument, uploadDocument/getDocumentStatus/downloadDocument, glossary CRUD, getUsage, listLanguages.TranslateTextOptions / RephraseTextOptions / TranslateDocumentOptions / TextTranslationOptions — constant classes so you don't hand-type 'formality', 'tag_handling', etc.src/Exceptions/ — this is what you actually code against.4. First use case — a one-liner to confirm credentials:
$client = app(\DeepL\DeepLClient::class);
$result = $client->translateText('Hello, world!', null, 'fr');
echo $result->text; // Bonjour, le monde !
echo $result->detectedSourceLang; // en
echo $result->billedCharacters; // 13
Note the second argument is the source language and may be null for auto-detect. That positional order (text, source, target, options) trips up nearly everyone on day one.
DeepLClient is a concrete, final-ish class with no interface, so you cannot $this->mock(DeepLClient::class) cleanly and you must never let tests hit the real API. Define a thin adapter:
interface Translator
{
public function translate(string $text, string $target, ?string $source = null, array $options = []): string;
}
class DeepLTranslator implements Translator
{
public function __construct(private \DeepL\DeepLClient $client) {}
public function translate(string $text, string $target, ?string $source = null, array $options = []): string
{
return $this->client->translateText($text, $source, $target, $options)->text;
}
}
Bind Translator → DeepLTranslator in the container and bind a fake in tests. Every downstream concern (caching, retries, quota guards) then has a seam.
Every call is an HTTP round trip plus rate-limit exposure. Pass an array:
$results = $client->translateText(
$paragraphs, // string[]
null,
'en-GB',
[TranslateTextOptions::PRESERVE_FORMATTING => true],
);
// $results is TextResult[] in the same order as input
Sum billedCharacters across results to record cost per request.
$key = 'deepl:'.hash('xxh128', $text.'|'.$source.'|'.$target.'|'.json_encode($options));
return Cache::remember($key, now()->addDays(30), fn () => $translator->translate($text, $target, $source, $options));
Cache at the segment level (one row per source string), not the whole page. CMS content that changes rarely can be translated once at save-time and stored in a translations table keyed by (model, field, locale) — this is the pattern that keeps you inside the Free tier indefinitely.
Raw markup will get mangled unless you tell the API:
$client->translateText($html, null, 'de', [
TranslateTextOptions::TAG_HANDLING => 'html',
TranslateTextOptions::IGNORE_TAGS => 'code,pre', // if supported in your version
TranslateTextOptions::PRESERVE_FORMATTING => true,
]);
For user-generated HTML prefer tag_handling => 'xml' with explicit splitting_tags / non_splitting_tags when you control the markup — more predictable than html.
context — it's freeCharacters in context are not billed. Feeding a few sentences of product/domain context markedly improves tone:
['context' => 'This is a B2B SaaS invoicing app. Use formal business language.']
For whole-domain terminology, pair it with a glossary rather than stuffing the prompt.
Create one per domain/product line, then pass the ID:
$glossary = $client->createGlossary('invoicing-de', 'de', 'en', \DeepL\GlossaryEntries::fromTsv($tsv));
$client->translateText($text, 'en', 'de', ['glossary' => $glossary->glossaryId]);
Cache glossaryId in config/DB — glossary IDs are account-scoped and change if you delete and recreate. Wrap lookups so a stale ID (404) falls back to a no-glossary translation instead of a 500.
class TranslateDocumentJob implements ShouldQueue
{
use Queueable;
public int $tries = 1; // do NOT auto-retry; each attempt re-uploads
public int $timeout = 600;
public function __construct(public string $documentId) {}
public function handle(\DeepL\DeepLClient $client): void
{
$in = Storage::disk('local')->path("docs/{$this->documentId}.docx");
$out = Storage::disk('local')->path("docs/{$this->documentId}.de.docx");
try {
$client->translateDocument($in, $out, 'en', 'de', ['formality' => 'more']);
} catch (\DeepL\DocumentTranslationException $e) {
if ($e->documentHandle) {
// persist handle so you can resume with getDocumentStatus() +
// downloadDocument() instead of re-uploading (and re-billing)
DocumentHandle::updateOrCreate(
['document_id' => $this->documentId],
['handle_id' => $e->documentHandle->documentId,
'handle_key' => $e->documentHandle->documentKey]
);
}
throw $e;
}
Storage::disk('s3')->put("translations/{$this->documentId}.de.docx", file_get_contents($out));
}
}
translateDocument() is a blocking convenience wrapper around upload → poll → download. For anything user-facing, use the three primitives directly so you can show progress and survive worker restarts.
$targets = Cache::remember('deepl:targets', now()->addDay(), fn () =>
collect($client->getTargetLanguages())->pluck('code')->all()
);
Validator::extend('deepl_target', fn ($attr, $value) => in_array($value, $targets, true));
Language codes are case-insensitive but normalize to lowercase (en-us → en-US) before storing so your cache keys and DB rows stay consistent.
try {
$usage = $client->getUsage();
// log / alert; inspect the Usage object properties (character counts + limits)
} catch (\DeepL\AuthorizationException $e) {
report($e); // bad or expired key — fail fast, don't retry
}
Catch QuotaExceededException at the boundaries and degrade gracefully: render the source text with a "translation unavailable" banner rather than a 500.
Return type flips with the input type. translateText('a', …) returns a single TextResult; translateText(['a'], …) returns TextResult[]. If a caller sometimes passes a string and sometimes an array, you get ->text on an array — always normalize to an array and index [0], or keep the string/array paths in separate methods.
The source argument is positional and often null. translateText($text, 'de', 'fr') means German→French, not auto→fr. Use named options arrays and a wrapper with a sensible signature.
formality is not universally supported. Passing 'less'/'more' to an unsupported target throws. Use 'prefer_less' / 'prefer_more' in any code path where the target language is user-selected — it degrades to default instead of erroring.
custom_instructions and model_type collide. Any request with custom_instructions defaults to quality_optimized; combining it with latency_optimized is rejected. Also: max 10 instructions, 300 chars each, and only certain target languages (de, en, es, fr, it, ja, ko, zh + variants).
rephraseText takes style or tone, never both. Passing both writing_style and tone is an error. Use prefer_* variants if the choice is user-driven.
Character billing is on the source, and retries can double-bill. If a request succeeds server-side but your client times out, the retry is billed again. Mitigate with segment-level caching (above) and by not blind-retrying on timeout for large batches — record what you sent.
translateDocument needs real local filesystem paths. Storage::path() works on the local disk; S3 needs a download to tmp/storage/app/temp first, then an upload of the output. Clean up temp files in a finally block.
30 MB document limit, and minification is pptx-only. ['minification' => true] only applies to the high-level translateDocument() (not uploadDocument()), and only helps pptx. For Word docs bloated with tracked changes/images, shrink them yourself before upload. You can emulate the high-level behaviour by instantiating DocumentMinifier and calling its methods between uploadDocument() and downloadDocument().
Don't $tries = 5 on document jobs. Each retry re-uploads. Persist documentHandle (from DocumentTranslationException) and resume with getDocumentStatus()/downloadDocument() — that's exactly why the exception carries it.
429 handling is yours. TooManyRequestsException is thrown on rate limits. Rely on queue concurrency limits (Redis::throttle, WithoutOverlapping) rather than a retry() loop inside a request — the API is a shared, metered resource and backoff-in-process will hold PHP-FPM workers hostage.
Never let the key reach the browser. No Livewire/Alpine/Inertia props, no VITE_ prefix, no client-side translate-on-keystroke. All translation goes through your own endpoint → queue → adapter.
DeepLClient has no interface. Mocking it with PHPUnit requires createMock on a concrete class with a constructor — brittle. The adapter contract from the patterns section is the workaround, and it's also where you'd swap in a stub provider for offline dev.
PHP version drift. The README states 7.3+, but support for EOL versions is being dropped and 8.1+ is the practical floor. If you're on an older Laravel/PHP combination, pin the package and check the CHANGELOG before composer update — the option constants have grown over time (styles, translation memories, rephrasing are recent additions), so a version bump can introduce behaviour changes in option handling.
Extension points are thin, by design. No events, no middleware, no PSR-18 injection in the public API. Your extension surface is: (a) the adapter contract, (b) a decorator around it for caching/logging/metrics, (c) the queue job layer, (d) DeepLClientOptions passed to the constructor for host/timeout/retry. Plan the architecture around those four seams rather than expecting to hook into the library itself.
Log billedCharacters per call. It's the only reliable way to attribute cost to a feature, tenant, or user, and it makes quota regressions visible before you hit the monthly wall.
How can I help you explore Laravel packages today?