castings(['status' => 'open']) as $casting) { * echo $casting['role_name'], PHP_EOL; * } * * Requires PHP 8.1+. * * @version 1.0.0 * * @released 2026-08-01 * * @license MIT */ namespace Castings; use ArrayIterator; use Countable; use IteratorAggregate; use Traversable; /** * One page of a list endpoint: iterate it for the records, read `meta` for the * pagination state. * * $page = $client->talents(['gender' => 'female']); * foreach ($page as $talent) { … } * $page->total(); // 477 * $page->hasMore(); // true */ class CastingsPage implements IteratorAggregate, Countable { /** * @param array> $items * @param array $meta */ public function __construct( public readonly array $items, public readonly array $meta, ) { } public function getIterator(): Traversable { return new ArrayIterator($this->items); } /** Records on this page. */ public function count(): int { return count($this->items); } /** Records across all pages. */ public function total(): int { return (int) ($this->meta['total'] ?? count($this->items)); } public function page(): int { return (int) ($this->meta['page'] ?? 1); } public function lastPage(): int { return (int) ($this->meta['last_page'] ?? 1); } public function hasMore(): bool { return $this->page() < $this->lastPage(); } } /** Base class for every error this client raises. */ class CastingsException extends \RuntimeException { /** @param array $response Decoded response body, when there was one. */ public function __construct( string $message, public readonly int $status = 0, public readonly array $response = [], ) { parent::__construct($message, $status); } } /** The token is missing, wrong or revoked (401). */ class CastingsAuthException extends CastingsException { } /** Unknown endpoint, or the record does not belong to your agency (404). */ class CastingsNotFoundException extends CastingsException { } /** Your plan does not include this feature (403). */ class CastingsForbiddenException extends CastingsException { } /** The payload failed validation (422). */ class CastingsValidationException extends CastingsException { /** * Field name → list of messages. * * @return array> */ public function errors(): array { return $this->response['errors'] ?? []; } /** First message for a field, or null when the field is fine. */ public function firstError(string $field): ?string { return $this->errors()[$field][0] ?? null; } } /** * This applicant already applied to the casting (409). Resubmit with * `update_existing` to refresh the stored application instead. */ class CastingsDuplicateException extends CastingsException { /** Id of the application that already exists. */ public function applicationId(): ?int { $id = $this->response['data']['id'] ?? null; return $id === null ? null : (int) $id; } } /** Rate limit or the plan's monthly application quota was reached (429). */ class CastingsRateLimitException extends CastingsException { /** Seconds to wait before retrying, when the server said so. */ public function retryAfter(): ?int { $seconds = $this->response['retry_after'] ?? null; return $seconds === null ? null : (int) $seconds; } } /** The request never got a usable reply (DNS, TLS, timeout, malformed JSON). */ class CastingsTransportException extends CastingsException { } class CastingsClient { /** Library version, sent as the User-Agent and shown on /docs/api. */ public const VERSION = '1.0.0'; /** Release date of this version (ISO-8601), shown on /docs/api. */ public const RELEASED = '2026-08-01'; private string $baseUrl; /** * @param string $token Agency API token (Agency → Settings → API). * @param string $baseUrl API root, without a trailing slash. * @param int $timeout Per-request timeout in seconds; uploads may need more. */ public function __construct( private readonly string $token, string $baseUrl = 'https://castings.com.ua/api/v1', private readonly int $timeout = 30, private readonly ?string $language = null, ) { if (! extension_loaded('curl')) { throw new CastingsTransportException('The PHP cURL extension is required.'); } $this->baseUrl = rtrim($baseUrl, '/'); } // ── Agency ─────────────────────────────────────────────────────────────── /** * The agency behind the token: branding, contacts, plan features, usage. * * @return array */ public function agency(): array { return $this->get('/agency'); } /** True when the token is valid and the API is reachable. */ public function ping(): bool { try { $this->agency(); return true; } catch (CastingsException) { return false; } } // ── Castings ───────────────────────────────────────────────────────────── /** * One page of published castings. * * @param array $filters status, gender, age, search, page, per_page */ public function castings(array $filters = []): CastingsPage { return $this->getPage('/castings', $filters); } /** * Every casting matching the filters, walking the pages for you. * * @param array $filters * @return \Generator> */ public function allCastings(array $filters = []): \Generator { yield from $this->walk('/castings', $filters); } /** @return array */ public function casting(int $id): array { return $this->get("/castings/{$id}"); } /** * The application form definition for a casting: which fields to render, * whether it still accepts applications, and the upload size caps. * * @return array */ public function castingFields(int $id): array { return $this->get("/castings/{$id}/fields"); } // ── Applications ───────────────────────────────────────────────────────── /** * Submit an application to a casting. * * @param array $fields Field keys from castingFields(). * @param array> $files Local paths, e.g. * ['photos' => ['/tmp/a.jpg', '/tmp/b.jpg'], 'video_demo_file' => '/tmp/reel.mp4'] * @param bool $updateExisting Refresh a previous application instead of failing with 409. * @return array The created (or updated) application. * * @throws CastingsDuplicateException This email already applied. * @throws CastingsValidationException The payload is incomplete or malformed. */ public function apply(int $castingId, array $fields, array $files = [], bool $updateExisting = false): array { if ($updateExisting) { $fields['update_existing'] = 1; } return $this->post("/castings/{$castingId}/applications", $fields, $files); } /** * One page of received applications. * * @param array $filters casting_id, status, source, since, page, per_page */ public function applications(array $filters = []): CastingsPage { return $this->getPage('/applications', $filters); } /** * @param array $filters * @return \Generator> */ public function allApplications(array $filters = []): \Generator { yield from $this->walk('/applications', $filters); } /** @return array */ public function application(int $id): array { return $this->get("/applications/{$id}"); } // ── Talents ────────────────────────────────────────────────────────────── /** * One page of your agency's talent database. * * @param array $filters search, gender, age_min, age_max, * height_min, height_max, include=contacts, page, per_page */ public function talents(array $filters = []): CastingsPage { return $this->getPage('/talents', $filters); } /** * @param array $filters * @return \Generator> */ public function allTalents(array $filters = []): \Generator { yield from $this->walk('/talents', $filters); } /** * @param bool $withContacts Include phone/email/messengers in the reply. * @return array */ public function talent(int $id, bool $withContacts = false): array { return $this->get("/talents/{$id}", $withContacts ? ['include' => 'contacts'] : []); } // ── Plumbing ───────────────────────────────────────────────────────────── /** * @param array $query * @return array */ private function get(string $path, array $query = []): array { $body = $this->request('GET', $path, $query); return $body['data'] ?? []; } /** @param array $query */ private function getPage(string $path, array $query): CastingsPage { $body = $this->request('GET', $path, $query); return new CastingsPage($body['data'] ?? [], $body['meta'] ?? []); } /** * @param array $fields * @param array> $files * @return array */ private function post(string $path, array $fields, array $files = []): array { $body = $this->request('POST', $path, [], $fields, $files); return $body['data'] ?? []; } /** * Page through a list endpoint until the last page. * * @param array $filters * @return \Generator> */ private function walk(string $path, array $filters): \Generator { $page = (int) ($filters['page'] ?? 1); do { $result = $this->getPage($path, $filters + ['page' => $page]); yield from $result->items; $page++; } while ($result->hasMore()); } /** * @param array $query * @param array $fields * @param array> $files * @return array */ private function request(string $method, string $path, array $query = [], array $fields = [], array $files = []): array { $url = $this->baseUrl.$path; if ($this->language !== null) { $query['lang'] = $this->language; } if ($query !== []) { $url .= '?'.http_build_query($query); } $headers = [ 'Authorization: Bearer '.$this->token, 'Accept: application/json', 'User-Agent: castings-php/'.self::VERSION, ]; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $this->timeout, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_HTTPHEADER => $headers, CURLOPT_CUSTOMREQUEST => $method, ]); if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $this->buildPostFields($fields, $files)); } $raw = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($raw === false) { throw new CastingsTransportException("Request to {$url} failed: {$error}"); } $body = json_decode((string) $raw, true); if (! is_array($body)) { throw new CastingsTransportException( "The API returned a non-JSON response (HTTP {$status}).", $status ); } if ($status >= 400) { throw $this->errorFor($status, $body); } return $body; } /** * Flatten fields + files into a cURL multipart payload. `photos` may be a * list, and each entry becomes its own `photos[]` part. * * @param array $fields * @param array> $files * @return array */ private function buildPostFields(array $fields, array $files): array { $payload = []; foreach ($fields as $key => $value) { if ($value === null) { continue; } if (is_bool($value)) { $value = $value ? 1 : 0; } if (is_array($value)) { foreach (array_values($value) as $i => $item) { $payload["{$key}[{$i}]"] = (string) $item; } continue; } $payload[$key] = (string) $value; } foreach ($files as $key => $paths) { foreach ((array) $paths as $i => $path) { if (! is_file($path) || ! is_readable($path)) { throw new CastingsTransportException("File not readable: {$path}"); } // A single-file field keeps its plain name; a list gets indexed // keys so PHP on the other side receives a proper array. $name = is_array($paths) ? "{$key}[{$i}]" : $key; $payload[$name] = new \CURLFile($path, mime_content_type($path) ?: null, basename($path)); } } return $payload; } /** @param array $body */ private function errorFor(int $status, array $body): CastingsException { $message = (string) ($body['message'] ?? "The API answered HTTP {$status}."); return match (true) { $status === 401 => new CastingsAuthException($message, $status, $body), $status === 403 => new CastingsForbiddenException($message, $status, $body), $status === 404 => new CastingsNotFoundException($message, $status, $body), $status === 409 => new CastingsDuplicateException($message, $status, $body), $status === 422 => new CastingsValidationException($message, $status, $body), $status === 429 => new CastingsRateLimitException($message, $status, $body), default => new CastingsException($message, $status, $body), }; } }