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
20 changes: 20 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true

[*.{yml,yaml,json,neon,xml,xml.dist}]
indent_size = 2

[*.md]
trim_trailing_whitespace = false

[tests/data/**]
insert_final_newline = unset
trim_trailing_whitespace = unset
indent_size = unset
16 changes: 16 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
* text=auto eol=lf

# Exact request bodies used by the signature tests: never touch their bytes.
/tests/data/*.raw.txt -text

# Keep the Packagist archive small.
/.github export-ignore
/examples export-ignore
/tests export-ignore
/.editorconfig export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
/.php-cs-fixer.dist.php export-ignore
/phpstan.neon.dist export-ignore
/phpunit.xml.dist export-ignore
/CONTRIBUTING.md export-ignore
58 changes: 50 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,60 @@ on:
branches: [main]
pull_request:

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
tests:
name: Tests (PHP ${{ matrix.php }}, ${{ matrix.dependencies }} dependencies)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php-version: ["8.1", "8.2", "8.3"]
php: ['8.1', '8.2', '8.3', '8.4', '8.5']
dependencies: [highest]
include:
- php: '8.1'
dependencies: lowest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
php-version: ${{ matrix.php-version }}
extensions: curl, json
persist-credentials: false
- uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: ${{ matrix.php }}
extensions: curl, json, mbstring
coverage: none
- run: composer install --no-interaction --prefer-dist
- run: vendor/bin/phpunit
tools: composer:v2
- name: Install dependencies
run: composer update --no-interaction --no-progress --prefer-dist ${{ matrix.dependencies == 'lowest' && '--prefer-lowest --prefer-stable' || '' }}
- name: Run the test suite
run: vendor/bin/phpunit

quality:
name: Static analysis, code style and coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
- uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: '8.4'
extensions: curl, json, mbstring
coverage: pcov
tools: composer:v2
- name: Validate composer.json
run: composer validate --strict
- name: Install dependencies
run: composer update --no-interaction --no-progress --prefer-dist
- name: PHPStan
run: vendor/bin/phpstan analyse --no-progress
- name: php-cs-fixer
run: vendor/bin/php-cs-fixer check --diff
- name: Coverage
run: vendor/bin/phpunit --coverage-text --only-summary-for-coverage-text
40 changes: 0 additions & 40 deletions .github/workflows/publish.yml

This file was deleted.

83 changes: 83 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
name: Release

on:
push:
tags: ['v*']

permissions:
contents: read

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
test:
name: Test ${{ github.ref_name }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
- uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: '8.4'
extensions: curl, json, mbstring
coverage: none
tools: composer:v2
- name: Check that the tag matches ShieldLabs::VERSION
run: |
version="$(sed -n "s/.*public const VERSION = '\(.*\)';/\1/p" src/ShieldLabs.php)"
if [ "v${version}" != "${GITHUB_REF_NAME}" ]; then
echo "::error::Tag ${GITHUB_REF_NAME} does not match ShieldLabs::VERSION (${version})."
exit 1
fi
- name: Validate composer.json
run: composer validate --strict
- name: Install dependencies
run: composer update --no-interaction --no-progress --prefer-dist
- name: Run the test suite
run: vendor/bin/phpunit

# Runs no Composer and no PHP code: only this job can write to the repository.
publish:
name: Publish ${{ github.ref_name }}
needs: test
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
- name: Create or update the GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
version="${GITHUB_REF_NAME#v}"
awk -v v="$version" 'index($0, "## [" v "]") == 1 {found = 1; next} found && /^## \[/ {exit} found {print}' CHANGELOG.md > release-notes.md
# Safe to re-run: when a later step failed, the release already exists and only its notes are refreshed.
if gh release view "${GITHUB_REF_NAME}" > /dev/null 2>&1; then
gh release edit "${GITHUB_REF_NAME}" --title "${GITHUB_REF_NAME}" --notes-file release-notes.md
else
gh release create "${GITHUB_REF_NAME}" --title "${GITHUB_REF_NAME}" --notes-file release-notes.md --verify-tag
fi
- name: Update the package on Packagist
env:
PACKAGIST_USERNAME: ${{ secrets.PACKAGIST_USERNAME }}
PACKAGIST_TOKEN: ${{ secrets.PACKAGIST_TOKEN }}
run: |
if [ -z "${PACKAGIST_USERNAME}" ] || [ -z "${PACKAGIST_TOKEN}" ]; then
echo "::error::Set the PACKAGIST_USERNAME and PACKAGIST_TOKEN secrets, then re-run this job."
exit 1
fi
# The token goes in a header, never in the URL.
curl --fail --silent --show-error -X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer ${PACKAGIST_USERNAME}:${PACKAGIST_TOKEN}" \
https://packagist.org/api/update-package \
-d '{"repository":{"url":"https://github.com/ShieldLabs-ai/shieldlabs-php"}}'
11 changes: 8 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
vendor/
composer.lock
/vendor/
/composer.lock
/.phpunit.cache/
/.phpunit.result.cache
/.php-cs-fixer.cache
/.phpstan.cache/
/coverage/
/build/
.env
.env.*
.DS_Store
.phpunit.result.cache
.history
23 changes: 23 additions & 0 deletions .php-cs-fixer.dist.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

$finder = (new PhpCsFixer\Finder())
->in([__DIR__ . '/src', __DIR__ . '/tests', __DIR__ . '/examples'])
->append([__FILE__]);

return (new PhpCsFixer\Config())
->setRiskyAllowed(true)
->setCacheFile(__DIR__ . '/.php-cs-fixer.cache')
->setRules([
'@PER-CS' => true,
'declare_strict_types' => true,
'no_unused_imports' => true,
'ordered_imports' => ['imports_order' => ['class', 'function', 'const'], 'sort_algorithm' => 'alpha'],
'single_quote' => true,
'native_function_invocation' => ['include' => ['@compiler_optimized'], 'scope' => 'namespaced', 'strict' => true],
'native_constant_invocation' => ['scope' => 'namespaced', 'strict' => true],
'no_empty_phpdoc' => true,
'phpdoc_trim' => true,
])
->setFinder($finder);
80 changes: 78 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,81 @@
# Changelog

## 2026-09-06
All notable changes to this package are documented in this file. The format is
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the package
follows [Semantic Versioning](https://semver.org/).

- Minor improvements and bug fixes
## [1.0.0] - 2026-09-30

First stable release, published as `shieldlabs/shieldlabs-php`. It replaces the
earlier preview package and its API.

### Added

- `ShieldLabs\ShieldLabs`: History API client for the Private API Key.
- `identifications->get($requestId)` waits for the verdict within a total time
budget (`timeout`, 10 s by default). It polls at once, then after waits of
250 ms, 500 ms, 1 s, 1.5 s and 2 s (1, 2, 4, 6 and 8 times `poll_interval`,
each at most 2 s, or at most `poll_interval` when that is longer, so 3 s polls
every 3 s), and one last time when the budget ends. Each poll is one HTTP
request without retries, limited to the time left but at least 1 s. A 429, a
5xx response, a connection error or a timeout keeps the polling going, and
such an error is thrown only when the last poll failed. After a 429 the next
poll waits at least 1 s: the longest of the next scheduled wait, 1 s and
`Retry-After` (up to 10 s; `Retry-After: 0` or a past date counts as 0), cut
short when the budget ends; when `Retry-After` is longer than the time left,
the 429 is thrown at once. 400, 401, 403, 404 and other errors end the wait at
once.
- `history->search($type, $value)` and the lazy `history->iterate()` generator,
which de-duplicates rows on `request_id` and stops after `max_items` rows
(`0` yields nothing and sends no request).
- Client-side validation of lookup types, UUIDs, IPv4 addresses, `limit` and
`offset`: invalid input throws `ValidationException` and sends nothing.
- User HID lookups are sent in canonical path form (`@`, `+`, `=`, `:` and
similar characters stay as they are), so such values match. A User HID that
contains `/`, or is `.` or `..`, throws `ValidationException`, because the
History API cannot search it.
- `ShieldLabs\ShieldLabsManagement::getProfile()`: domain profile from the
Management API, with domain normalization. It never retries a 429.
- `ShieldLabs\Webhook::verifySignature()` and `Webhook::constructEvent()`: signature
checks over the raw body with one secret or a list (for rotation), and typed
events (`IdentificationScoredEvent`, `WebhookPingEvent`, `UnknownWebhookEvent`).
- One `Identification` model for webhook payloads and History rows, with typed
`IpInfo`, `TrafficSource`, `Signal` and `DetectionFlags` (19 flags; missing flags
are false).
- `Risk::band()`, `Risk::isRateLimited()`, `Risk::evaluate()` (freshness, replay,
rate-limit marker, device signals, flags and bands) and `UserHid::fromUserId()`.
- Exception hierarchy under `ShieldLabs\Exception`, including
`QuotaExceededException` (402) and `RateLimitException::getRetryAfter()`.
- Keys, secrets and domains are checked when a client is created: a value with
characters that cannot be sent in an HTTP header (line breaks, NUL, spaces,
non-ASCII) throws `ValidationException`, and no error message repeats a key or
secret. The built-in cURL client refuses header lines with line breaks.
- Retries with exponential backoff and jitter for connection errors, timeouts, 429
(History API) and 5xx, following `Retry-After` as sent (up to 10 s). A 429
without `Retry-After` waits at least 1 s. A timeout or a dropped connection while
the response body is read (Symfony HttpClient streams it) counts as a timeout or
connection error too.
- Base URLs must use https. Plain http is accepted for `localhost`, `127.0.0.1`
and `[::1]`, and for other hosts only with the `allow_insecure_http` option, so a
mistyped URL never sends a key unencrypted.
- HTTP client selection that applies the `timeout` option by default: a Guzzle 7 or
Symfony HttpClient found through PSR-18 discovery is created with the timeout,
otherwise the built-in cURL client is used. A client passed as `http_client` is used
as is. The built-in `Http\CurlClient` has `getTimeout()` and `withTimeout()`; with
it, and with the Guzzle or Symfony client the SDK creates, a poll of
`identifications->get()` gets the shorter timeout.
- SDK warnings, such as an API key in an unexpected format, go to the PHP error log
through `error_log()`, once per process, and never into a response.
- Runnable example: `examples/server.php` (signup guard and webhook receiver).

### Fixed

- Requests go to `https://account.shieldlabs.ai/api/v1/history/...`; the preview
built `/api/api/...` paths. A base URL ending in `/api` is now accepted and
normalized.

### Removed

- The preview `ShieldLabs\Client` class and the `shieldlabs/shieldlabs` package name.

[1.0.0]: https://github.com/ShieldLabs-ai/shieldlabs-php/releases/tag/v1.0.0
Loading
Loading