Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .github/workflows/php-integration-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: PHP integration tests

# Language-specific workflow: only runs for the PHP client. Triggers on PRs to master that touch
# PHP client, test, example, or documentation code, and can be dispatched manually from any branch.
on:
pull_request:
branches: [master]
paths:
- '**/*.php'
- 'composer.json'
- 'composer.lock'
- '.php-cs-fixer.dist.php'
- 'phpstan.neon.dist'
- 'phpunit.xml.dist'
# The "Test examples" step validates the in-documentation snippets, so doc changes must
# re-run the workflow even though Markdown is not PHP code.
- 'docs/**'
- 'README.md'
- '.github/workflows/php-integration-tests.yml'
workflow_dispatch:

# Avoid concurrent runs of the same ref racing on the shared test account.
concurrency:
group: php-integration-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
extensions: mbstring, json, curl
coverage: none
tools: composer:v2

- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress

# The formatter/lint gate mandated by the coding rules (PSR-12 via php-cs-fixer).
- name: Check formatting (php-cs-fixer)
run: composer lint

# Static-analysis gate mandated by the coding rules.
- name: Static analysis (PHPStan)
run: composer stan

# Offline unit tests (mock transport): prove the retry/error/signature logic without the API.
- name: Unit tests
run: vendor/bin/phpunit --testsuite unit

# Fail fast if the integration-test secret is missing or empty. Without this guard the
# integration tests silently "pass" (they self-skip when APIFY_TOKEN is unset), so a green run
# would not prove the API logic actually executed.
- name: Require APIFY_TOKEN secret
env:
APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }}
run: |
if [ -z "${APIFY_TOKEN}" ]; then
echo "::error::APIFY_TOKEN secret is empty or missing; integration tests would not run against the API."
exit 1
fi

- name: Integration tests
env:
# The integration-test token is stored as a repository secret.
APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }}
run: vendor/bin/phpunit --testsuite integration

# Standalone CI step that verifies the documentation examples work end-to-end against the live
# API (ExamplesTest runs each example) and that every in-documentation PHP snippet is valid,
# runnable code (DocSnippetsTest lints each fenced block).
- name: Test examples
env:
APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }}
run: vendor/bin/phpunit --testsuite examples
114 changes: 114 additions & 0 deletions .github/workflows/php-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
name: Publish PHP client

# Language-specific publish workflow for the PHP client. Triggered manually only (workflow_dispatch)
# so a maintainer deliberately decides when a release is cut. The release version is the single
# source of truth in src/Version.php (CLIENT_VERSION). The package is distributed via Packagist,
# which builds the release from the pushed Git tag; this workflow tags the release, creates the
# GitHub release, and notifies Packagist to update.
#
# Packagist does not offer OIDC "Trusted Publishing"; its update API requires a username + API token,
# which are read from repository secrets (nothing is stored in the repo).
on:
workflow_dispatch:
inputs:
dry_run:
description: 'Run all checks but do not tag, release, or notify Packagist.'
type: boolean
default: false

# Never allow two publish runs to race; publishing two releases concurrently is hard to undo.
concurrency:
group: php-publish
cancel-in-progress: false

permissions:
contents: write # create the tagged GitHub release

jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

# A release must only ever be cut from master.
- name: Require master branch
run: |
if [ "${GITHUB_REF}" != "refs/heads/master" ]; then
echo "::error::Publishing is only allowed from master, but this run is on '${GITHUB_REF}'."
exit 1
fi

- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
extensions: mbstring, json, curl
coverage: none
tools: composer:v2

- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress

# Gate the release on the same quality bar as CI so a broken build can never be published.
- name: Check formatting (php-cs-fixer)
run: composer lint

- name: Static analysis (PHPStan)
run: composer stan

- name: Unit tests
run: vendor/bin/phpunit --testsuite unit

- name: Resolve version from src/Version.php
id: version
run: |
version=$(php -r "require 'src/Version.php'; echo Apify\Client\Version::CLIENT_VERSION;")
if ! echo "${version}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::Version::CLIENT_VERSION '${version}' is not a bare semver (X.Y.Z)."
exit 1
fi
echo "tag=v${version}" >> "$GITHUB_OUTPUT"
echo "Resolved release tag: v${version}"

- name: Ensure tag does not already exist
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
if git ls-remote --exit-code --tags origin "${TAG}" >/dev/null 2>&1; then
echo "::error::Tag ${TAG} already exists on origin; bump Version::CLIENT_VERSION first."
exit 1
fi

- name: Create and push release tag
if: ${{ github.event.inputs.dry_run != 'true' }}
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "${TAG}" -m "Release ${TAG}"
git push origin "${TAG}"

- name: Create GitHub release
if: ${{ github.event.inputs.dry_run != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
run: |
gh release create "${TAG}" \
--title "${TAG}" \
--notes "Apify PHP client ${TAG}. See CHANGELOG.md for details."

# Notify Packagist to pull the new tag. Credentials come from repository secrets.
- name: Notify Packagist
if: ${{ github.event.inputs.dry_run != 'true' }}
env:
PACKAGIST_USERNAME: ${{ secrets.PACKAGIST_USERNAME }}
PACKAGIST_TOKEN: ${{ secrets.PACKAGIST_TOKEN }}
run: |
curl -sS -XPOST -H'content-type:application/json' \
"https://packagist.org/api/update-package?username=${PACKAGIST_USERNAME}&apiToken=${PACKAGIST_TOKEN}" \
-d'{"repository":{"url":"https://packagist.org/packages/apify/apify-client"}}'
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,11 @@ composer.phar
/vendor/

# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
# The lock file is committed so CI installs a deterministic, resolved dependency set
# (including the phpstan static-analysis gate). https://getcomposer.org/doc/01-basic-usage.md
# composer.lock

# Tooling caches
.phpunit.cache/
.phpunit.result.cache
.php-cs-fixer.cache
26 changes: 26 additions & 0 deletions .php-cs-fixer.dist.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

/*
* Code-style configuration for the Apify PHP client. Enforces PSR-12 plus a small
* set of widely-used hygiene rules. Run `composer fix` to apply, `composer lint` to check.
*/

$finder = PhpCsFixer\Finder::create()
->in(__DIR__ . '/src')
->in(__DIR__ . '/tests');

return (new PhpCsFixer\Config())
->setRiskyAllowed(true)
->setRules([
'@PSR12' => true,
'declare_strict_types' => true,
'array_syntax' => ['syntax' => 'short'],
'no_unused_imports' => true,
'ordered_imports' => ['sort_algorithm' => 'alpha'],
'single_quote' => true,
'trailing_comma_in_multiline' => true,
'no_trailing_whitespace' => true,
])
->setFinder($finder);
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Changelog

## 0.1.0

- Initial PHP client for the Apify API (spec `v2-2026-07-02T131926Z`).
- Resource clients for Actors, Actor versions and environment variables, builds, runs, datasets,
key-value stores, request queues, tasks, schedules, webhooks, webhook dispatches, the Apify Store,
users, and logs.
- Convenience helpers consistent with the JS reference client: `actor()->call()`/`start()`,
`validateInput()`, `defaultBuild()`, `lastRun()`, run `abort`/`metamorph`/`reboot`/`resurrect`/
`charge`/`waitForFinish`, dataset `listItems`/`downloadItems`/`pushItems`/public URLs, key-value
store records and public URLs, request queue batch add with retries, lazy request/store iteration,
and log streaming. `lastRun(status, origin)` filters propagate to the run's nested dataset,
key-value store, request queue, and log accessors, so they resolve the same run.
- `batchAddRequests` requires a non-empty `uniqueKey` per request, splits batches by both the 25-request
count limit and the ~9 MiB payload-size limit, and retries only the requests the API reports
unprocessed in a successful response (previously it hard-coded an empty unprocessed list and could
mis-correlate requests). Consistent with the reference client, a failed batch call reports that
chunk's not-yet-processed requests as unprocessed rather than throwing.
- `datasets()->getOrCreate()` and `keyValueStores()->getOrCreate()` accept an optional `schema`.
- `requestQueue($id, RequestQueueClientOptions)` accepts `clientKey` and `timeoutSecs`; `paginateRequests()`
accepts `PaginateRequestsOptions` (`limit`, `maxPageLimit`, `exclusiveStartId`, `cursor`, `filter`).
- Replaceable HTTP transport (`HttpClientInterface`) with a default Guzzle implementation and a
PSR-18 adapter; automatic retries with exponential backoff and jitter, growing per-attempt
timeouts, and HMAC-SHA256 storage URL signing.
- Public `Version::CLIENT_VERSION` and `Version::API_SPEC_VERSION` constants.
- Integration test suite, documentation with runnable examples, and CI workflows for integration
tests and publishing.
118 changes: 116 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,116 @@
# apify-client-php
Apify API client for PHP—Programmatically run Actors, manage and stream data from storages (datasets, key-value stores, request queues), schedule and monitor runs, and access the full Apify platform API. Sync and async interfaces with automatic retries and pagination.
# Apify API client for PHP

> **Official, but experimental — AI-generated and AI-maintained.** This is an official Apify client,
> but it is experimental: it is generated and maintained by AI. Review the code before relying on it
> in production and report issues on the repository.

A resource-oriented PHP client for the [Apify API](https://docs.apify.com/api/v2), mirroring the
official [JavaScript](https://github.com/apify/apify-client-js) reference client: start from an
`ApifyClient`, then drill down into resources (Actors, runs, datasets, key-value stores, request
queues, tasks, schedules, webhooks, the store, users and logs).

## Requirements

- PHP 8.1 or newer, with the `json`, `mbstring` and `hash` extensions.

## Installation

```bash
composer require apify/apify-client
```

## Quick start

All snippets assume the client types are imported from their namespaces, e.g.
`use Apify\Client\ApifyClient;`.

```php
$client = new ApifyClient('my-api-token');

// Start an Actor and wait for it to finish. The last argument is the wait budget in seconds;
// pass a value (e.g. 120) to bound the wait, or null to wait indefinitely (as here).
$run = $client->actor('apify/hello-world')->call(null, null, null);

// Read items from the run's default dataset.
$items = $client->dataset($run->getDefaultDatasetId())->listItems();
echo 'Item count: ' . $items->getCount() . PHP_EOL;
```

`new ApifyClient('my-api-token')` takes the token as an explicit argument — it does **not** read
`APIFY_TOKEN` (or any other environment variable) automatically. Read it yourself if you want that,
e.g. `new ApifyClient(getenv('APIFY_TOKEN'))`.

Get your API token from the
[Apify Console → Settings → API & Integrations](https://console.apify.com/settings/integrations).

## Configuration

The constructor accepts named arguments for non-default settings:

```php
$configured = new ApifyClient(
token: 'my-api-token',
maxRetries: 5,
minDelayBetweenRetriesMillis: 1000,
timeoutSecs: 120,
userAgentSuffix: 'my-app/1.2.3',
);
```

| Argument | Default | Meaning |
|---|---|---|
| `token` | `null` | API token, sent as a Bearer token. |
| `baseUrl` | `https://api.apify.com` | API base URL; the `/v2` suffix is appended automatically. |
| `publicBaseUrl` | `baseUrl` | Base URL used when building public, shareable resource URLs. |
| `maxRetries` | `8` | Maximum retries for failed requests. |
| `minDelayBetweenRetriesMillis` | `500` | Minimum delay between retries (exponential backoff). |
| `maxDelayBetweenRetriesMillis` | request timeout | Upper bound on the growing inter-retry delay. |
| `timeoutSecs` | `360` | Overall per-request timeout. |
| `userAgentSuffix` | `null` | Custom suffix appended to the `User-Agent` header. |
| `httpClient` | Guzzle | The replaceable transport (`Apify\Client\Http\HttpClientInterface`). |

Requests are retried on network errors, HTTP 429 (rate limit) and 5xx responses, with exponential
backoff and jitter. 4xx responses (other than 429) are thrown immediately as `ApifyApiException`.

### Replaceable HTTP transport

The transport is the `Apify\Client\Http\HttpClientInterface`. The default is `GuzzleHttpClient`; you
can wrap any [PSR-18](https://www.php-fig.org/psr/psr-18/) client with `Psr18HttpClient`, or provide
your own implementation:

```php
$client = new ApifyClient(token: 'my-api-token', httpClient: new GuzzleHttpClient());
```

## Error handling

Methods that fetch a single resource return `null` when the resource does not exist (rather than
throwing). Other API failures are thrown as `Apify\Client\Exception\ApifyApiException`, which exposes
the HTTP status, API error `type`, message, attempt count, and request method/path:

```php
try {
$client->actor('does/not-exist')->update(['title' => 'x']);
} catch (ApifyApiException $e) {
echo $e->getStatusCode() . ' ' . $e->getType() . ': ' . $e->getApiMessage() . PHP_EOL;
}
```

## Versioning

- `Apify\Client\Version::CLIENT_VERSION` — the semantic version of this library.
- `Apify\Client\Version::API_SPEC_VERSION` — the Apify OpenAPI spec version this client was built
against.

```php
echo Version::CLIENT_VERSION . ' / ' . Version::API_SPEC_VERSION . PHP_EOL;
```

## Documentation

Full documentation lives in [`docs/`](docs/README.md), organized by resource, with runnable
[examples](docs/examples.md).

## License

[Apache-2.0](LICENSE).
Loading
Loading