akeneo/crowdin-api
A PHP client for the Crowdin API that makes it easy to manage translation projects, files, strings, and localizations from your application. Provides a clean, typed wrapper around Crowdin endpoints for automating localization workflows.
This is still a plain PHP SDK, not a Laravel package — no service provider, facade, or config file to publish. But v2.0.0 replaces Guzzle with Symfony's HttpClient, which changes your integration surface significantly. Plan to wire it yourself.
composer require akeneo/crowdin-api
Version check first: 2.0.0 pulls in symfony/http-client ^6.0. That means PHP 8.1+ and effectively Laravel 9+ (Laravel 8 ships Symfony 5 and will conflict). If you're stuck on Laravel 8, pin to the 1.x line and accept the Guzzle-based assessment from before.src/ before the README. The README is thin; the src/ directory is the real documentation. In 2.0.0 look specifically at how the client constructor now accepts an HttpClientInterface — if it does, you can inject your own (with retries, logging, timeouts baked in) rather than letting the SDK build one.HttpClientInterface:
// config/services.php
'crowdin' => [
'token' => env('CROWDIN_TOKEN'),
'project_id' => env('CROWDIN_PROJECT_ID'),
'base_uri' => env('CROWDIN_BASE_URI', 'https://api.crowdin.com'),
],
// App\Providers\AppServiceProvider::register()
$this->app->singleton(\Symfony\Contracts\HttpClient\HttpClientInterface::class, function () {
return new \Symfony\Component\HttpClient\RetryableHttpClient(
\Symfony\Component\HttpClient\HttpClient::create([
'timeout' => 60,
'max_duration' => 300,
]),
new \Symfony\Component\HttpClient\Retry\GenericRetryStrategy(
maxRetries: 3,
delayMs: 500,
),
);
});
$this->app->singleton(\Crowdin\Client::class, fn ($app) => new \Crowdin\Client(
config('services.crowdin.token'),
config('services.crowdin.base_uri'),
// pass $app->make(HttpClientInterface::class) if the constructor accepts it
));
Adjust class name / constructor signature to whatever src/ actually exposes — this is the part most likely to have changed in 2.0.0.Wrap the client in your own service. Never inject the raw SDK into controllers. Create an App\Services\CrowdinService that exposes domain methods (syncProducts(), downloadTranslations(string $locale)) and returns your own DTOs/arrays. This isolates the SDK's exception types and lets you swap it later — even more important now that the HTTP layer has changed under you once already.
namespace App\Services;
use Crowdin\Client;
class CrowdinService
{
public function __construct(private Client $client) {}
public function translationsFor(string $locale): array
{
// translate SDK calls into your own shape here
}
}
Push/pull as queued jobs. Crowdin operations are network-bound and can be slow (large file uploads, translation builds). Keep controllers fast:
SyncTranslationsJob::dispatch($locale)->onQueue('translations');
Give each job $timeout = 300 and $tries = 3 with backoff() — Symfony HttpClient will still occasionally throw transport timeouts, and you want them retried at the job layer.
Configure retries at the HTTP client, not the job. With v2.0.0 you get Symfony's RetryableHttpClient, which retries idempotent failures (5xx, timeouts) transparently. Wrap the base client once in the container as shown above. Do not also retry 5xx yourself in jobs or you'll multiply the load on Crowdin's side during incidents.
Storage API is a two-step flow. Crowdin doesn't accept raw file bodies on most file endpoints. You upload to the Storage endpoint first, get a storage ID back, then reference that ID when creating/updating a source file. Model this as two methods in your service, not one, so you can retry the second step independently. Note that with RetryableHttpClient the upload itself will be retried on transient failures, but you shouldn't blindly retry the file-create step (it's not idempotent — you can create duplicates).
Scheduled sync via Artisan. A php artisan crowdin:sync command scheduled in Kernel/routes/console.php is the cleanest day-to-day ergonomic for keeping translations fresh. Inside the command, call your service — not the SDK directly.
Webhooks instead of polling. Configure Crowdin project webhooks to hit a route in your app on translation.updated / file.translated. Verify the signature header before trusting the payload, then dispatch a job that pulls the changed file. This replaces expensive polling loops. The HTTP client change doesn't affect this, but the webhook handler should hand off to your service, not reach into the SDK.
Testing — the big change. Guzzle's MockHandler is gone. You now test with Symfony\Component\HttpClient\MockHttpClient and MockResponse:
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
$mock = new MockHttpClient([
new MockResponse(json_encode(['data' => ['id' => 1]]), ['response_headers' => ['content-type' => 'application/json']]),
]);
// bind $mock into the container in place of HttpClientInterface, then resolve Crowdin\Client
Simpler still: bind a fake CrowdinService in the container and skip the SDK entirely for unit tests. Keep an integration test suite (separate group, run in CI nightly) against a sandbox Crowdin project — especially important right after a major version, since the response handling is fresh code now.
src/ and the changelog before upgrading, and budget half a day for the migration.composer update. Older apps cannot use 2.0.0 without also upgrading Symfony, which cascades into framework upgrades.env() outside config files breaks under config:cache. Always read config('services.crowdin.*'), never env() at runtime.TransportExceptionInterface, ClientExceptionInterface, ServerExceptionInterface) plus any Crowdin-specific exceptions the SDK wraps them in. Catch \Throwable at your service boundary — or better, catch HttpExceptionInterface for HTTP failures and \RuntimeException for the rest — and convert to your own exception (or report() + a user-facing message) so callers never see raw HTTP types.RetryableHttpClient retries only by default on connection errors and 5xx. By 2.0.0's design, 4xx will not be retried — correct, because Crowdin 429s are the one you do want to retry. Configure a GenericRetryStrategy with a RetryStrategyInterface-aware decision callback if you want to retry 429s with Retry-After backoff.HandlerStack/MockHandler needs porting to MockHttpClient/MockResponse. MockResponse can also throw (return a \Throwable from the callback) to simulate transport failures — that's how you exercise your retry/exception paths.vendor/akeneo/crowdin-api as something you may fork. Cross-check behavior against Crowdin's REST docs (v2) rather than trusting the docblocks — especially now, since the HTTP-layer rewrite may have introduced subtle changes to how responses are deserialized.Link header until exhausted.RateLimiter are safer than firing hundreds of parallel requests. With Symfony's client the default is sequential per process, so you're only in danger if you dispatch many parallel jobs — throttle job dispatch, not the client.timeout / max_duration in the options array of HttpClient::create()), so no more decorator fallback needed — just make sure the SDK actually uses the injected client and doesn't build its own.x-request-id header Crowdin returns in error responses — support can trace it. Symfony's TraceableHttpClient (enabled in dev via the profiler) replaces Guzzle's debug handler; never enable it in production as it logs full request bodies including tokens.HttpClientInterface into the SDK client, (b) subclassing/wrapping the client, (c) decorating your own service. Design your app so a future SDK replacement only touches CrowdinService.How can I help you explore Laravel packages today?