tourze/wechat-work-external-contact-bundle
Symfony bundle from tourze that integrates WeChat Work (企业微信) external contact features into PHP applications. Use it to wire external contact management into an existing Symfony or tourze-based project with minimal configuration and boilerplate.
This bundle wraps WeCom (企业微信) External Contact APIs — the "客户联系" surface used for customer lists, customer tags, group chats, and contact-change callbacks. The 0.0.2 release ships no documented changes beyond the compare link, so treat 0.0.1 and 0.0.2 as functionally identical until you read the actual diff (composer diff-style: git log 0.0.1..0.0.2 --stat on the GitHub compare page).
Minimal path:
composer require tourze/wechat-work-external-contact-bundle — then immediately pin the version in composer.json ("0.0.2", not ^0.0). At 0.0.x, patch bumps can carry breaking signature changes.php artisan vendor:publish --tag=wechat-work-external-contact. In the Tourze ecosystem some bundles register config purely via Symfony-style extension classes — if the publish command finds nothing, check src/DependencyInjection/ and src/Resources/config/ and register the service provider manually in bootstrap/providers.php.WECHAT_WORK_CORP_ID, WECHAT_WORK_AGENT_ID, and critically the external contact secret (客户联系 Secret from the admin console) — not the app secret. They are different secrets with different scopes and using the wrong one yields 60011 no privilege.WECHAT_WORK_CALLBACK_TOKEN, WECHAT_WORK_CALLBACK_AES_KEY, WECHAT_WORK_CORP_ID — the AES key is 43 chars, and the callback URL must be reachable over plain GET for the initial verification.Where to look first: the API client class (grep for getAccessToken / HttpClient / Guzzle) and the DTOs/response models. That single file tells you whether the bundle caches tokens itself or expects you to. Everything else is plumbing around it.
First use case: backfill your local customer table. Resolve the client from the container, call the "get external contact list" endpoint with a staff userid, then "get external contact detail" per external_userid, and upsert into a wecom_customers table keyed on external_userid. That single job proves credentials, permissions, pagination, and rate limiting all at once — do it before touching callbacks.
Bind the client, don't new it. Resolve through the container so you can swap it in tests:
$client = app(\Tourze\WechatWorkExternalContactBundle\Client\ExternalContactClient::class);
If no facade is provided, wrap it in your own thin WecomExternalContact service and bind that — you want one seam to mock later.
Paginated sync as a queued job, cursor-driven. Every list endpoint returns next_cursor; loop until empty, but never loop unbounded inside a request. Shape it as: a SyncExternalContacts job that processes one page, dispatches itself with the next cursor, and stops on empty cursor. Guard against cursor stalls (log and bail if the same cursor returns twice).
Change callbacks are the real integration surface. WeCom pushes change_external_contact events with ChangeType values: add_external_contact, del_external_contact, add_half_external_contact, edit_external_contact, del_follow_user, transfer_fail. Pattern:
// routes/web.php — no auth middleware, no CSRF
Route::post('/wecom/callback', WecomCallbackController::class);
Controller: verify signature → decrypt → verify ToUserName equals your CorpID → dispatch a job with the payload → return 200 with an empty body or the decrypted echostr for GET verification. Do the actual work (fetching contact detail, writing rows) in the queued job, because the API call is slow and WeCom retries aggressively on timeout.
Mirror tables, not live API reads. Persist external_userid, userid (the staff member), name, type (1=WeChat user, 2=WeCom user), gender, avatar, corp_name, follow_user[], and your own synced_at. Detail calls are rate-limited and slow; your UI should never block on them.
Tagging is the cheapest automation lever. mark_tag / add_tag are idempotent-ish and let you drive segmentation from Laravel (e.g. order_placed → add tag). Keep a wecom_tags table mapping your internal slugs to WeCom tag_ids so renames in the admin console don't break code.
Multi-tenant / multi-corp: if you operate more than one CorpID, the client must be resolved per-corp, not as a singleton. Access tokens are per-corp-per-secret and will silently overwrite each other in a shared cache key.
Testing: Http::fake() if the bundle uses Laravel's HTTP client; otherwise mock the client class in the container. Snapshot the raw JSON from a real sandbox response and fixture it — WeCom's error envelopes are inconsistent enough that hand-written fakes miss fields.
access_token is the #1 source of 40001/42001 errors. It lives 7200s and fetching a new one invalidates the old one for some secret types. Cache it in Redis with a ~7000s TTL, use a lock around the fetch (Cache::lock), and never fetch per-request. If the bundle doesn't cache, write the decorator yourself before anything else.60011 or an empty external_userid list with code 0 — the silent empty result is the nastier failure mode. Log the raw response body on every sync, not just on exceptions.external_userid + ChangeType + EventTime, and keep a seen-events table or you'll get duplicate audit rows and duplicate side effects (emails, tags).msg_signature and the AES payload must match; a wrong AES key (wrong length, trailing newline from .env) produces the same opaque error. Log both computed and provided signatures during setup, then remove the log.->delay() between job dispatches rather than a sleep() in a worker.follow_user can hold multiple staff for one customer (shared customers). Don't key your table on (userid, external_userid) unless you want duplicates; key on external_userid and store the staff array.git diff 0.0.1 0.0.2 -- src/ and look specifically at method signatures on the client and any renamed DTO properties. Budget for the possibility that the package is a thin generated wrapper (Tourze bundles often are), in which case consider calling the HTTP API directly through a small, well-tested service of your own and dropping the dependency.Bundle classes with no service provider. If app(...) throws Target [X] is not instantiable, you're constructing it via new with a Guzzle client and a token provider — write the binding yourself in AppServiceProvider and move on.curl outside the app with a freshly minted token. If curl works and the app fails, it's token caching or secret selection. If curl also fails, it's IP allowlist (configure it in the admin console) or the corp not having 客户联系 enabled at all.How can I help you explore Laravel packages today?