crowdin/crowdin-api-client
Official PHP client for Crowdin's REST API, letting Laravel and PHP applications manage localization projects, source strings, translations, glossaries, and builds programmatically. Wraps every endpoint in a clean, object-oriented SDK with typed requests and responses for Crowdin Cloud and Enterpris...
Install and wire it up. There is no Laravel service provider shipped with the package, so the first ten minutes are yours:
composer require crowdin/crowdin-api-client
Add credentials to config/services.php rather than calling env() at the call site:
'crowdin' => [
'project_id' => env('CROWDIN_PROJECT_ID'),
'api_token' => env('CROWDIN_API_TOKEN'),
// Enterprise only — presence of this key switches the client to Enterprise endpoints
'organization' => env('CROWDIN_ORGANIZATION'),
],
Bind it once as a singleton in AppServiceProvider:
$this->app->singleton(\CrowdinApiClient\Crowdin::class, fn () => new \CrowdinApiClient\Crowdin(
array_filter(config('services.crowdin'))
));
Where to look first. Open src/CrowdinApiClient/Crowdin.php. Everything the library can do is discoverable from that one file: the class-level @property docblock enumerates every service, and the $services / $servicesEnterprise arrays show the exact string you pass to __get(). That docblock is your API index — read it before opening any Api/*.php class.
Your first use case should be a read-only smoke test, not a write. Pull the project and a page of source strings:
$crowdin = app(\CrowdinApiClient\Crowdin::class);
$project = $crowdin->projects->get($projectId);
$strings = $crowdin->sourceStrings->list($projectId, ['limit' => 20]);
If that returns hydrated models, your token, project ID, and (for Enterprise) organization key are all correct. Do this in a php artisan tinker session before writing a single job.
Set expectations early. This is a thin transport layer over the REST API, not a sync engine. It gives you request signing, model hydration, and JSON-Patch diffs. It does not give you delta detection, retry logic, rate-limit backoff, caching, or conflict resolution — those are your application's job, and they are where the real work lives.
Treat the client as infrastructure, not domain code. Wrap it in your own thin adapter with the three or four operations your app actually performs (pushSourceFile, pullTranslations, listGlossaryTerms). Inject the adapter, not Crowdin directly. This keeps the magic-__get() surface out of your business logic, gives you a seam for faking in tests, and means a package upgrade breaks one file instead of forty.
Fetch → mutate → update. Never construct. AbstractApi::update() computes a JSON-Patch by diffing the model's current property values against the original payload it was hydrated from. A model you build by hand has no original payload, so the diff is garbage and the update silently sends nothing. The only correct flow is:
$string = $crowdin->sourceStrings->get($projectId, $stringId); // hydrate
$string->setText('Updated copy'); // mutate
$crowdin->sourceStrings->update($projectId, $string); // diff & PATCH
If a write "succeeds" but the value never changes in the UI, this is the reason nine times out of ten.
Own the async lifecycle. File uploads are a two-step Storage flow: push the bytes to storage, then reference the returned storage ID when creating/updating the source file. After that, builds are asynchronous — you poll translations->build/project status, you don't assume completion. Model this as a small state machine on your side (queued job that re-dispatches itself with backoff until the build reports finished), never as a blocking loop in a request.
Put every write behind a queued job with explicit backoff. Crowdin rate-limits, and this client raises the underlying HTTP exception rather than retrying for you. Laravel's job middleware is the right place:
public function middleware(): array
{
return [(new \Illuminate\Queue\Middleware\RateLimited('crowdin'))->releaseAfter(10)];
}
public $tries = 5;
public $backoff = [10, 30, 60, 120];
Define the crowdin limiter in a service provider with a conservative Limit::perSecond(2) and tune upward once you've watched real traffic.
Cache aggressively on the read path, invalidate on the write path. Project metadata, file trees, and glossaries change rarely and are cheap to memoize:
$files = Cache::remember("crowdin:files:{$projectId}", 600, fn () =>
$crowdin->sourceFiles->list($projectId, ['limit' => 500])
);
Flush these keys in the same job that performs the write. A CrowdinApi::forget($projectId) helper that busts a tagged cache group pays for itself the first week.
Paginate in jobs, never in the request cycle. A mature project has tens of thousands of strings. limit/offset options on the list methods are your only tool; loop in a chunked job and write to your own table, then let the UI read from your database. This also gives you a natural place to record lastSyncedAt and to compute deltas instead of full re-pulls.
Use the package as the extension point, not a blocker. When you hit an endpoint the client doesn't cover, the conventions in the repo make it cheap to add locally: extend AbstractApi, use the relative path (no leading slash) with _list/_get/_create/_update/_delete, and keep the model property names identical to the API JSON keys. Alternatively, for one-off calls, drop to the raw HTTP layer through your adapter rather than contorting a model that doesn't fit.
Property names are load-bearing. A model property whose name does not exactly match the API's JSON key will hydrate to null on read and will never appear in the patch on write. No exception, no warning. When something is mysteriously missing from an update, diff your model's property list against the raw JSON response before you debug anything else. Nested config objects additionally need a toArray() to serialize into the patch.
__get() means typos fail late and quietly. $crowdin->sourceStrings works; $crowdin->sourceStringsApi and $crowdin->source_strings do not — the service name resolves by ucfirst($name) . 'Api'. Because it's magic, PHPStan/Larastan needs the class-level @property docblock (which ships with the package) to see the services; if your static analysis is unhappy, point it at that docblock rather than suppressing the error.
Enterprise silently substitutes base classes. If an Api/Enterprise/<Name>Api class exists, it replaces the base implementation for Enterprise clients with no runtime signal. The same method can have different parameters or semantics depending on environment. When you support both, integration-test Enterprise paths separately — a green test suite against the standard API proves nothing.
Project ID is usually an explicit argument. Even though it may be in your config array, most methods take $projectId as the first parameter. Passing it twice, or assuming the config value covers you, produces a confusing 404 rather than a validation error.
Dates and IDs arrive as raw values. Hydrated models are plain BaseModel subclasses, not Eloquent and not Carbon-aware. getCreatedAt() gives you a string; cast it yourself. IDs may be int or string depending on the endpoint — don't type-hint int in your own DTOs without normalizing first.
Exceptions bubble up as HTTP-layer errors. There's no domain exception hierarchy to catch. Catch by status code through the underlying client exception, and map 401 (bad token), 403 (wrong org/permissions), 404 (wrong project or ID), and 429 (rate limit) into your own exception types at the adapter boundary so callers can react sensibly.
Testing needs a seam. Http::fake() won't intercept this — the package brings its own Guzzle stack. The pragmatic answer is the adapter interface described above: bind a fake adapter in the container and assert on your own method calls. If you truly need to exercise the client, construct Crowdin with a Guzzle MockHandler in a unit test, isolated from your application tests.
PHP 7.1 syntax constraints only apply inside src/. The package deliberately avoids typed properties, constructor promotion, and match so it runs on 7.4–8.5. Your application code is under no such restriction — don't downgrade your own style to match the vendor's. It only matters if you fork or contribute.
Contributing is a five-file transaction. A new resource touches the Api class, the model, three registration points in Crowdin.php (the @property docblock, $services, $servicesEnterprise), and two test files. Note that an update test must actually mutate the model — _update() performs no HTTP request when nothing changed, so a naive test passes without exercising anything. And every public method needs two @link lines (developer.crowdin.com and its Enterprise counterpart) because CI builds phpDocumentor from those docblocks; malformed PHPDoc fails the build.
Weigh the dependency honestly. 62 stars and negligible downstream usage means few eyes on the code and a high chance you'll be reading src/ to answer your own questions — which is fine, because it's small and MIT-licensed. Budget for that: prefer the adapter boundary, pin the version in composer.json, and keep your Crowdin integration to a handful of files so a package stall or a breaking change is an afternoon, not a sprint.
How can I help you explore Laravel packages today?