amocrm/amocrm-api-library
Official PHP SDK for integrating with amoCRM. It handles OAuth 2.0 authentication, REST API requests, entities such as leads and contacts, custom fields, and webhooks, with client and model classes that simplify building CRM integrations.
Install and pin. The package ships no Laravel service provider, so you wire it yourself.
composer require amocrm/amocrm-api-library:^5.0
Pin the major and commit composer.lock — this library cuts minor releases frequently (last release 2026-07-01) and the changelog is Russian-only, so surprise breaks are a real cost.
Where to look first. src/AmoRestClient.php is the whole surface area — every $client->leads(), ->contacts(), ->notes() accessor lives there. Then src/Oauth/ for the token model, and src/*/ for entity models (LeadModel, ContactModel, NoteModel, request/filter classes). The README is the primary doc and is in Russian; treat the source as the spec.
Step 1 — one-time OAuth, stored in your DB. amoCRM is per-account OAuth2, so you run the authorization-code flow once per portal and persist the pair.
// app/Services/Amo/AuthFactory.php
$auth = new AmoCRMOAuth(
config('services.amocrm.client_id'),
config('services.amocrm.client_secret'),
config('services.amocrm.redirect_uri'),
);
$auth->setBaseDomain(config('services.amocrm.domain')); // acme.amocrm.ru
// callback
$token = $auth->getAccessTokenByAuthorizationCode($request->query('code'));
AmoAccount::updateOrCreate([...], [
'access_token' => $token->getToken(),
'refresh_token' => $token->getRefreshToken(),
'expires_at' => now()->addSeconds($token->getExpiresIn()),
]);
Step 2 — build the client per account.
$auth = (new AmoCRMOAuth($id, $secret, $redirect))
->setBaseDomain($account->domain);
$token = new Token();
$token->setToken($account->access_token)
->setRefreshToken($account->refresh_token)
->setExpiresIn($account->expires_at->diffInSeconds(now(), false));
$auth->setAccessToken($token);
$client = new AmoRestClient($auth);
$client->setAccountBaseDomain($account->domain);
Step 3 — first use case: website form → lead + contact.
$contact = (new ContactModel())
->setFirstName($data['first_name'])
->setCustomFieldsValues(new CustomFieldsValuesCollection([
(new CustomFieldValueModel())
->setFieldCode('EMAIL')
->setValues([(new CustomFieldValueModel())->setValue($data['email'])]),
]));
$contact = $client->contacts()->addOne($contact);
$lead = (new LeadModel())
->setName('Website: pricing enquiry')
->setPipelineId($pipelineId)
->setStatusId($incomingStatusId)
->setResponsibleUserId($userId)
->setContactsId([$contact->getId()]);
$client->leads()->addOne($lead);
That create-plus-link flow is the 80% case and is a good smoke test that your token plumbing works.
Bind a per-account client in the container, never new it inline. One portal per tenant is normal, so resolve by ID rather than a global singleton:
// app/Providers/AmoServiceProvider.php
$this->app->bind(AmoRestClient::class, fn ($app) => $app->make(AmoClientFactory::class)
->forAccount($app->make(AmoAccount::class)));
Wrap it in your own AmoGateway with domain methods (createLead(), syncContact()) so the ~40 service accessors never leak into controllers.
Refresh centrally, not in call sites. Access tokens last ~24h; refresh tokens rotate. Put the check in the factory and take a lock:
Cache::lock("amo:token:{$account->id}", 15)->block(10, function () use ($account) {
$account->refresh();
if ($account->expires_at->isFuture()) return;
$new = (new AmoCRMOAuth($id, $secret, $redirect))
->setBaseDomain($account->domain)
->getAccessTokenByRefreshToken($account->refresh_token);
$account->update([...]);
});
Queue every write. API calls are network-bound at 7 req/s per integration; synchronous writes from a web request will time out and, worse, double-submit. Use dispatch() + retry(5, [1000, 5000, 15000]) and backoff on 429.
Bulk sync with pagination + chunked jobs. For reads, page the collection and hand each page to a job:
$collection = $client->leads()->get(
(new LeadsRequest())
->setLimit(250)
->setPage($page)
->setWith(['contacts'])
->setFilter((new LeadsFilterModel())->setUpdatedAt($since, $until))
);
->setWith(['contacts']) is the single biggest win — it collapses an N+1 into one call per page. Filter on updated_at as a checkpoint column, keyed on id for idempotency, so a re-run is safe.
Cache reference data aggressively. Pipelines, statuses, users and custom-field definitions change rarely and cost a request each:
Cache::rememberForever("amo:{$account->id}:custom_fields:leads", fn () =>
$client->customFields('leads')->get()->all()
);
Map field names to IDs once and store the map; resolve field_id from it rather than hardcoding integers in code.
Webhooks are thin. amoCRM webhooks deliver account/entity IDs and little else, and they're not signed. Receive, verify the portal (account.subdomain), enqueue, then re-fetch the entity through the API in the job. Exclude the route from CSRF in bootstrap/app.php.
Error handling by class, not message. Catch AmoCRMApiException and branch on getErrorCode() / getStatusCode():
401 → force refresh, retry once.429 → release the job with exponential backoff.400 with validation errors → log getLastRequestInfo(), do not retry.Testing. AmoRestClient accepts a PSR-18 HTTP client, so inject a fake returning canned JSON and unit-test your gateway without hitting the portal. This is the cheapest way to keep sync jobs trustworthy.
setAccountBaseDomain() is required and easy to forget; without it you get opaque 4xx errors. Also don't confuse it with the OAuth setBaseDomain().AmoCRMApiNoContentException, a separate class from the general API exception. Wrap deletes in a catch or your jobs will "fail" on success.add()/update() with a bigger collection will be rejected; chunk to 250 (conservative: 200) and queue the chunks.RateLimiter won't see these calls — use a Redis token bucket around the gateway if you have many workers.setFieldCode() only works for system codes (EMAIL, PHONE); anything custom needs setFieldId() from your cached map. Multiselect/enum values need setEnumId() alongside setValue(), and the value object shape differs per field type — dump one real entity with getLastRequestInfo() before writing a mapper.getLastRequestInfo() is your debugger. It contains the last request/response pair; log it on every 4xx. Without it, this library's exceptions are nearly information-free.setContactsId() on a new lead behaves differently across versions and account configs; if linking silently drops, fall back to creating the lead, then updating it with the _embedded.contacts payload.TokenStorageInterface to let the library persist tokens directly, and decorate the PSR-18 client with a Guzzle middleware for structured logging and retries instead of wrapping every call site.src/ and for owning your own abstraction layer — the library is a competent HTTP/model layer, not a framework integration.How can I help you explore Laravel packages today?