geekcodev/max-php-client
A PHP client for the MAX messaging platform API, offering convenient methods to authenticate, send messages, and handle updates within Laravel or plain PHP applications. Simplifies integration with MAX bots and services.
The package (geekco/max-php-client) targets PHP 8.4 and is installed normally via Composer. v1.1.11 is additive on the public surface: existing constructor signatures are unchanged, new parameters (Attachment::$filename, Subscription::$time, Callback::$timestamp/$user, FailedUserDetails::$errorCode/$userIds, attachment payload metadata) are appended in final positions, and one enum value was added. Any composer.json constraint allowing ^1.1 picks it up — but see the ChatStatus::Suspended caveat below, which is the release's only behavioural change.
Where to look first, in order:
README.md — install, client construction, MAX_API_TOKEN env wiring.docs/api-reference.md — the public API surface. New since v1.1.8: GeekCo\MaxPhpClient\Security\ContactPhoneExtractor, ChatStatus::Suspended, Attachment::$filename, Subscription::$time, Callback::$timestamp/$user, FileAttachmentPayload::$filename/$size, VideoAttachmentPayload::$width/$height/$thumbnail, AudioAttachmentPayload::$transcription, the mp4_*/hls fields on VideoUrls.examples/verify-contact.php — the canonical end-to-end path: Update::fromArray() → AttachmentType::Contact → ContactAttachmentPayload → ContactVerifier → ContactPhoneExtractor, including signature verification and rejection of a tampered phone number.tests/Dto/AttachmentTest.php, tests/WebhookHandlerTest.php, tests/Dto/CoverageTest.php — these are the living spec for v1.1.11: null/missing hash, dual-form metadata parsing, suspended chats, callback user fallback, both FailedUserDetails shapes.First use case — a user shares a contact card with your bot, and you need the phone number:
use GeekCo\MaxPhpClient\Security\ContactPhoneExtractor;
$phone = $contact->vcfPhone
?? ContactPhoneExtractor::fromVcf((string) $contact->vcfInfo);
That single line is the whole feature: it returns the value of the first non-empty TEL property from the contact's vcf_info, verbatim, or null if there is none. Since v1.1.11 this path is finally reliable end to end: a contact webhook with hash: null or no hash at all used to make ContactAttachmentPayload::fromArray() throw InvalidResponseException, your webhook controller answered 400 invalid_payload, and MAX dropped the update after retries. Now hash degrades to '' and the update reaches your handler. Copy the snippet from examples/verify-contact.php rather than re-deriving it.
Inbound vs. outbound asymmetry. Treat vcf_phone as an outbound-only field on ContactAttachmentPayload. Inbound payloads from MAX do not carry it, which is exactly why ContactPhoneExtractor exists. The ?? chain above is the intended shape — always fall back to the extractor, never assume vcfPhone is populated.
Wrap it in a small application service. Because the extractor is a stateless static method with no dependencies, it composes cleanly anywhere: a job handler, a listener on your update-processing pipeline, or a form request:
public function phoneFromContact(ContactAttachmentPayload $contact): ?string
{
return $contact->vcfPhone
?? ContactPhoneExtractor::fromVcf((string) $contact->vcfInfo);
}
Keeping this in one place means that when you later add country-aware canonicalization, you change one line, not every call site.
Canonicalize at the boundary, not in the extractor. The library deliberately returns the number as-is (the confirmed real-world capture was a Russian number without +). If the phone is used as a key — CRM upsert, lead dedup, unique index — normalize (E.164, strip separators, add country code) in your own code before persisting or comparing. Do it once, immediately after extraction, and store both raw and canonical forms if you need traceability.
Signature verification comes before extraction. Follow the example's ordering: verify the update signature, then read the contact, then extract. Extracting first and validating later lets unverified PII flow through your logs and queues. v1.1.11 reinforces this: an empty hash yields ContactVerifier::verify() === false (fail-closed), so an unsigned phone is never promoted to "confirmed" — it is simply an unverified number you may still choose to store.
Stop rejecting contacts at your own webhook boundary. If your controller or form request mirrors the old strict schema (hash required, string), you re-introduce the exact 400 that v1.1.11 fixes. Validate on the fields you actually use, not on a hand-copied contract.
File attachments: read the name, not the URL. Attachment::$filename now carries the uploaded file's name, parsed from the top level first and from payload as a fallback. Replace any basename(parse_url($url, PHP_URL_PATH)) logic — that is where the service-name getfile artefact came from. Size comes from FileAttachmentPayload::$size; keep a fallback to file_size while legacy responses are still in flight.
Video and audio: consume renditions and metadata instead of guessing. VideoAttachmentPayload now exposes width/height/thumbnail plus duration, and VideoUrls carries mp4_1080 … mp4_144, hls, and the legacy mp4/preview. A typical helper picks the best available rendition with a fallback chain, so you do not hard-code mp4 for new traffic:
$url = $video->urls->mp4_720
?? $video->urls->mp4
?? $video->urls->hls;
AudioAttachmentPayload::$transcription is now populated — do not re-run speech-to-text on media that already arrives transcribed.
Model ChatStatus with a default branch. Any match (ChatStatus) in your code must now handle suspended or provide a fallback. This is the one place v1.1.11 can break a green build, and it will surface as UnhandledMatchError at runtime, not at deploy.
Callback events carry their own sender. Callback::$timestamp and Callback::$user are populated, and Update::$user falls back through callback.user after the top level and message.sender. Resolve the acting user once in a single accessor rather than repeating the three-step chain in every handler.
Bulk member adds: parse both result shapes. FailedUserDetails accepts the spec shape {error_code, user_ids[]} and the production shape {user_id, error}, and its required-field contract was relaxed. Iterate user_ids from AddChatMembersResult rather than assuming one failure per entry.
Use it for deduplication workflows. The extractor replaces a hand-rolled telFromVcf() in a real integration, with two concrete wins: vCard folded-line unfolding (a long number split across a line break previously landed truncated in the database) and case-insensitive property matching (tel: as well as TEL). If you have any legacy vCard regex sitting in your codebase, this is a drop-in replacement for it.
Testing. Test against fixtures, not live events — a contact update can only be produced by a real MAX user, so full end-to-end automation is not possible. Keep .vcf fixtures byte-exact under tests/Fixtures/vcard/; the package's own .gitattributes pins *.vcf -text precisely so CRLF fixtures survive cloning. For the new fields, assert with a captured webhook JSON body (contact without hash, file attachment with filename, suspended chat) rather than constructing DTOs by hand — the dual-form parsing is exactly what you want covered.
ChatStatus::Suspended is the only behaviour change in v1.1.11. Chats that previously threw InvalidResponseException now parse. Audit every exhaustive match/switch on ChatStatus and add a default branch before upgrading.hash is not a verified contact. fromArray() coerces missing/null hash to '', and verify() returns false for it. Do not treat "the update parsed" as "the phone is signed". Store the verification result alongside the number.toArray() omits empty hash and null filename. If you diff serialized payloads in tests or logs, the absent keys are expected, not a bug. Outbound round-trips never emit hash: "".payload. filename/size/width/height/thumbnail/transcription are read from the top level first and payload second, yet toArray() writes the payload metadata into payload and filename at the top level — deliberately matching both upstream schemas. Do not "normalise" this asymmetry in your own DTOs.file_size is still legacy. FileAttachmentPayload::$size is the forward field; file_size remains because the official schema does not confirm it. Removal is a v2.0.0 matter.mp4 and preview on VideoUrls are deprecated in practice. They still resolve for old responses but will go away in v2.0.0; prefer the explicit mp4_*/hls chain and treat the legacy pair as a fallback only.FailedUserDetails cannot represent user_id: 0. Missing fields now degrade to 0/'' rather than throwing, so userId === 0 is a sentinel for "not provided" and is dropped from toArray(). Production IDs start at 1, but strict nullable fields are a v2.0.0 change.addChatMembers() is no longer deprecated. v1.1.6 listed it, AddChatMembersResult, and FailedUserDetails as heading for removal; v1.1.11 reverses that and the @deprecated tag is gone. If you built an alternative path to avoid it, you can retire that workaround.+, strip spaces or dashes, or reformat. TEL;TYPE=cell:79161234567 comes back as 79161234567. Anything downstream that assumes E.164 will break.TEL only. A contact with home + work + mobile yields one number. If you need all of them, this class is not the tool — parse the full vcf_info yourself.X-TEL is not a phone. Custom X-TEL properties are deliberately skipped, so an exported contact that only has X-TEL returns null. Don't treat null as "no phone exists" without checking the raw vcf_info.item1.TEL;TYPE=cell:+123 parses correctly; don't strip the prefix yourself before calling.vcf_info may contain literal escapes. After json_decode, line breaks can arrive as real CRLF/CR/LF or as literal \r\n/\n/\r. The extractor handles both — but if you pre-process the string yourself, you may destroy the folding semantics it relies on. Pass the raw string in.vcf_info, the extracted number, and Attachment::$filename as personal data. The library neither logs nor throws; that responsibility is yours. Mask numbers in logs and exception messages, keep them out of failed-job payload dumps, and be careful with Log::info($update->toArray())-style debugging that captures whole payloads — filenames often contain real names.null is not an error. No exceptions are thrown by the extractor, so a null return means "no TEL found", not "broken input". Branch accordingly rather than wrapping in try/catch.dev → main merge, tag, GitHub Release, and Packagist publication are triggered only on explicit request. Verify 1.1.11 resolves on Packagist before pinning — published versions are immutable, so a re-tagged or force-pushed tag gets blocked and fixes arrive only as new version numbers. Don't hold a dev-main constraint expecting to re-resolve an old tag.ContactPhoneExtractor is one static method around one regex (/^(?:[A-Za-z0-9-]+\.)?TEL(?:;[^:]*)?:(.*)$/i); wrap it rather than forking the regex into your app, or you lose folded-line handling. For DTO gaps, prefer a thin adapter over the public properties and open an upstream issue — the v1.1.11 changelog shows dual-schema drift is actively reconciled.How can I help you explore Laravel packages today?