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
56 changes: 54 additions & 2 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,63 @@ concurrency:
cancel-in-progress: true

jobs:
format:
runs-on: ubuntu-latest
name: Auto-fix check (PHPCBF)
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 #4.1.6

- name: Build PHP container
run: docker build --build-arg "PHP_VERSION=8.3" -t php --file docker/Dockerfile docker/

- name: Start PHP container
run: |
docker run -d --name php -v ${GITHUB_WORKSPACE}:/app php tail -f /dev/null

- name: Install dependencies
run: docker exec php /bin/sh -c "composer install"

- name: Apply PHPCBF (must leave tree clean)
run: docker exec php /bin/sh -c "bin/phpcbf"

- name: Fail if formatting differed
run: git diff --exit-code

- name: Stop PHP container
if: always()
run: docker stop php

psalm:
runs-on: ubuntu-latest
strategy:
matrix:
php-version: ["8.1", "8.2", "8.3", "8.4", "8.5"]
name: Psalm (PHP ${{ matrix.php-version }})
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 #4.1.6

- name: Build PHP container
run: docker build --build-arg "PHP_VERSION=${{ matrix.php-version }}" -t php --file docker/Dockerfile docker/

- name: Start PHP container
run: |
docker run -d --name php -v ${GITHUB_WORKSPACE}:/app php tail -f /dev/null

- name: Install dependencies
run: docker exec php /bin/sh -c "composer install"

- name: Run Psalm
run: docker exec php /bin/sh -c "composer psalm -- --php-version=${{ matrix.php-version }}"

- name: Stop PHP container
if: always()
run: docker stop php

lint:
runs-on: ubuntu-latest
strategy:
matrix:
php-version: ["7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3", "8.4", "8.5"]
php-version: ["8.1", "8.2", "8.3", "8.4", "8.5"]
name: Lint (PHP ${{ matrix.php-version }})
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #4.1.1
Expand All @@ -42,7 +94,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
php-version: ["7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3", "8.4", "8.5"]
php-version: ["8.1", "8.2", "8.3", "8.4", "8.5"]
name: PHP v${{ matrix.php-version }}
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #4.1.1
Expand Down
11 changes: 9 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,22 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Changed
### Removed

## 4.0.0 - 2026-05-06
### Added
- Composer scripts `cs-check` / `cs-fix` (PHPCS / PHPCBF); CI job **Auto-fix check** ensures the tree matches `phpcbf` output.
- Psalm (`psalm.xml`, `psalm-baseline.xml`): static analysis at `errorLevel="1"`. CI runs **`composer psalm`** (dev dependency `vimeo/psalm` **6.16.1**, aligned with `psalm-baseline.xml`).
### Changed
- **Breaking:** minimum supported PHP version is now **8.1** (PHP 7.x and 8.0 are no longer supported). This matches runtime requirements already implied by `EventType` (enum) and typed properties on `WebhookSubscription`.

## 3.2.0 - 2026-05-05
### Added
- `Document::getContent(bool $signed = true)` — downloads document content as raw binary via the `/content` endpoint (no base64 JSON round-trip). Decrypted content only; the SDK does not expose `decrypt=false`.
- `Document::setFile(string $filePath)` — preferred setter; sends the API `file` field (PDF uploads today; naming aligns with upcoming non-PDF support).
- `Document::getFormat()` — returns the document format as reported by the API (typically `"pdf"` for current use).
- `Entity::getBinaryContent()` — low-level helper for fetching raw binary responses from asset endpoints.
### Changed
- `Document::getPdf()` now delegates to `getContent()` and is deprecated. The underlying endpoint changed from the deprecated `/pdf` (base64) to `/content` (binary). Return value is unchanged (raw binary string).
- `Document::setPdfFile()` is deprecated in favour of `setFile()`.
- `Document::getPdf()` now delegates to `getContent()` and is deprecated; optional `bool $signed = true` matches `getContent()`. The underlying endpoint changed from the deprecated `/pdf` (base64) to `/content` (binary). Return value is unchanged (raw binary string).
- `Document::setPdfFile()` is deprecated in favor of `setFile()`.

## 3.1.1 - 2026-05-05
### Added
Expand Down
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ about how to become a customer.

## Prerequisites

The Penneo SDK for PHP requires PHP 7.2 or newer (PHP 8 is supported). The PHP extensions `json` and `openssl` are required.
The Penneo SDK for PHP requires **PHP 8.1 or newer**. The PHP extensions `json` and `openssl` are required.

If your application still runs on PHP 7.x or 8.0, stay on the **3.x** SDK line (e.g. `"penneo/penneo-sdk-php": "^3.2"`) and plan an upgrade.

## Getting Started

Expand All @@ -27,7 +29,7 @@ Next, update your project's composer.json file to include the SDK:
```json
{
"require": {
"penneo/penneo-sdk-php": "^3.0"
"penneo/penneo-sdk-php": "^4.0"
}
}
```
Expand Down Expand Up @@ -187,6 +189,28 @@ request.
You should add a logger by calling `ApiConnector::setLogger()`. If you contact support, please include any
relevant `requestIds` you find in the logs.

### Static analysis (Psalm)

CI runs [Psalm](https://psalm.dev/) on `src/` (`errorLevel="1"`, `psalm-baseline.xml`). With **PHP ^8.1**, Psalm is a normal **dev** dependency (`vimeo/psalm`); after `composer install` use:

```bash
composer psalm
composer psalm:baseline # refresh psalm-baseline.xml after fixing/suppressing issues
```

(`bin/psalm` is the Composer-generated proxy to `vendor/vimeo/psalm/psalm`.)

### Code style (PHPCS / PHPCBF)

`phpcs.xml` applies **PSR-12** to `src/` and `tests/`. CI runs `bin/phpcs` on every PHP version and a dedicated job runs **`bin/phpcbf`** then fails if anything changed (so auto-fixable drift is caught).

Locally:

```bash
composer cs-check # bin/phpcs
composer cs-fix # bin/phpcbf — apply fixes, then commit
```

### Document signing

* [Folders][folder-docs]
Expand Down
11 changes: 9 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
}
},
"require": {
"php": "^7.2|^8.0",
"php": "^8.1",
"psr/log": "^1.0|^2.0|^3.0",
"ext-json": "*",
"guzzlehttp/guzzle": "^7.2",
Expand All @@ -27,11 +27,18 @@
"phpunit/phpunit": "^8.5|^9.0",
"squizlabs/php_codesniffer": "*",
"blastcloud/chassis": "1.0.4|>=1.1",
"blastcloud/guzzler": "2.0.2|>=2.2"
"blastcloud/guzzler": "2.0.2|>=2.2",
"vimeo/psalm": "6.16.1"
},
"config": {
"bin-dir": "bin"
},
"scripts": {
"cs-check": "phpcs",
"cs-fix": "phpcbf",
"psalm": "@php bin/psalm --no-cache",
"psalm:baseline": "@php bin/psalm --update-baseline"
},
"archive": {
"exclude": [
"tests/",
Expand Down
2 changes: 1 addition & 1 deletion docs/cli-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Without real credentials, programmatic fails at token exchange (e.g. HTTP 401) a

### Interactive OAuth on localhost (step by step)

Penneo sends the user back to an URL you register on the OAuth client. **That URL, the address bar in the browser, and `PENNEO_OAUTH_REDIRECT_URI` must be identical** (including `http` vs `https`, `localhost` vs `127.0.0.1`, port, path, and **`/interactive_oauth_example.php`** — the file extension is required).
Penneo sends the user back to a URL you register on the OAuth client. **That URL, the address bar in the browser, and `PENNEO_OAUTH_REDIRECT_URI` must be identical** (including `http` vs `https`, `localhost` vs `127.0.0.1`, port, path, and **`/interactive_oauth_example.php`** — the file extension is required).

1. In Penneo (sandbox), add a redirect URI such as:
`http://127.0.0.1:8080/interactive_oauth_example.php`
Expand Down
2 changes: 2 additions & 0 deletions docs/programmatic_oauth_example.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

require_once dirname(__DIR__) . '/vendor/autoload.php';

use Penneo\SDK\ApiConnector;
use Penneo\SDK\CaseFile;
use Penneo\SDK\OAuth\Config\Environment;
Expand Down
2 changes: 2 additions & 0 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<files psalm-version="6.16.1@f1f5de594dc76faf8784e02d3dc4716c91c6f6ac"/>
54 changes: 54 additions & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?xml version="1.0"?>
<psalm
errorLevel="1"
resolveFromConfigFile="true"
findUnusedVariablesAndParams="false"
findUnusedCode="false"
phpVersion="8.1.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
errorBaseline="psalm-baseline.xml"
>
<projectFiles>
<directory name="src"/>
<ignoreFiles>
<directory name="vendor"/>
</ignoreFiles>
</projectFiles>

<issueHandlers>
<!-- Legacy SDK: dynamic property hydration from API payloads -->
<MissingConstructor errorLevel="suppress"/>
<!-- Typed class constants (PHP 8.3+ syntax); SDK stays PHP 8.1-compatible until min is raised -->
<MissingClassConstType errorLevel="suppress"/>
<PropertyNotSetInConstructor errorLevel="suppress"/>
<UnusedClass errorLevel="suppress"/>
<PossiblyUnusedMethod errorLevel="suppress"/>
<ClassMustBeFinal errorLevel="suppress"/>
<MissingOverrideAttribute errorLevel="suppress"/>
<LessSpecificReturnType errorLevel="info"/>
<MissingClosureReturnType errorLevel="info"/>
<!-- Incremental typing: keep CI green while improving annotations over time -->
<MissingPropertyType errorLevel="info"/>
<MissingParamType errorLevel="info"/>
<MissingReturnType errorLevel="info"/>
<MixedArgument errorLevel="info"/>
<MixedAssignment errorLevel="info"/>
<MixedOperand errorLevel="info"/>
<MixedArrayAccess errorLevel="info"/>
<MixedArrayOffset errorLevel="info"/>
<MixedReturnStatement errorLevel="info"/>
<MixedPropertyFetch errorLevel="info"/>
<MixedMethodCall errorLevel="info"/>
<MixedArgumentTypeCoercion errorLevel="info"/>
<MixedPropertyTypeCoercion errorLevel="info"/>
<RiskyTruthyFalsyComparison errorLevel="info"/>
<LessSpecificReturnStatement errorLevel="info"/>
<MoreSpecificReturnType errorLevel="info"/>
<MoreSpecificImplementedParamType errorLevel="info"/>
<LessSpecificImplementedReturnType errorLevel="info"/>
<DocblockTypeContradiction errorLevel="info"/>
<ImplicitToStringCast errorLevel="info"/>
</issueHandlers>
</psalm>
47 changes: 33 additions & 14 deletions src/ApiConnector.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public static function initializeWsse(
?string $endpoint = null,
?int $user = null,
?array $headers = null
) {
): void {
self::$headers = array_merge(
$headers ?: [],
self::getDefaultHeaders(),
Expand Down Expand Up @@ -100,26 +100,31 @@ public static function setLogger(LoggerInterface $logger): void
self::$logger = $logger;
}

public static function readObject(Entity $object)
public static function readObject(Entity $object): bool
{
$response = self::callServer($object->getRelativeUrl() . '/' . $object->getId());
$id = $object->getId();
if ($id === null) {
return false;
}
$response = self::callServer($object->getRelativeUrl() . '/' . $id);
if (!$response) {
return false;
}
$object->__fromJson($response->getBody()->getContents());
return true;
}

public static function writeObject(Entity $object)
public static function writeObject(Entity $object): bool
{
$data = $object->__getRequestData();
if ($data === null) {
return false;
}

if ($object->getId()) {
$id = $object->getId();
if ($id !== null) {
// Update request
$response = self::callServer($object->getRelativeUrl() . '/' . $object->getId(), $data, 'put');
$response = self::callServer($object->getRelativeUrl() . '/' . $id, $data, 'put');
if (!$response) {
return false;
}
Expand All @@ -129,23 +134,37 @@ public static function writeObject(Entity $object)
if (!$response) {
return false;
}
$object->__fromJson($response->getBody(true));
$object->__fromJson($response->getBody()->getContents());
}

return true;
}

public static function deleteObject(Entity $object)
public static function deleteObject(Entity $object): bool
{
if (!self::callServer($object->getRelativeUrl() . '/' . $object->getId(), null, 'delete')) {
$id = $object->getId();
if ($id === null) {
return false;
}
if (!self::callServer($object->getRelativeUrl() . '/' . $id, null, 'delete')) {
return false;
}

return true;
}

public static function callServer($url, $data = null, $method = 'get', $options = array()): ?Response
{
/**
* @param false|null|string $data
* @param array[] $options
*
* @psalm-param array{query?: array} $options
*/
public static function callServer(
string $url,
$data = null,
string $method = 'get',
array $options = []
): ?Response {
Comment on lines +162 to +167

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In Guzzle 7, what are the declared return types of GuzzleHttp\Client::request()andGuzzleHttp\Exception\RequestException::getResponse()GuzzleHttp\Psr7\ResponseorPsr\Http\Message\ResponseInterface?

💡 Result:

In Guzzle 7, the declared return type of GuzzleHttp\Client::request is Psr\Http\Message\ResponseInterface [1][2]. The declared return type of GuzzleHttp\Exception\RequestException::getResponse is?Psr\Http\Message\ResponseInterface (nullable ResponseInterface) [3]. Guzzle uses PSR-7 interfaces throughout, not its concrete Psr7 classes in method signatures [4][5][6].

Citations:


🏁 Script executed:

cat -n src/ApiConnector.php | sed -n '155,200p'

Repository: Penneo/sdk-php

Length of output: 1940


🏁 Script executed:

head -50 src/ApiConnector.php

Repository: Penneo/sdk-php

Length of output: 1382


🏁 Script executed:

rg "interface ResponseInterface" --type php -A 20

Repository: Penneo/sdk-php

Length of output: 40


🌐 Web query:

Does PSR-7 ResponseInterface have getHeader() method? What methods are in ResponseInterface?

💡 Result:

Yes. PSR-7 ResponseInterface (Psr\Http\Message\ResponseInterface) has getHeader($name) method (inherited from MessageInterface) [1][2]. Methods in Psr\Http\Message\ResponseInterface - From MessageInterface: - getProtocolVersion [1][2] - withProtocolVersion($version) [2][1] - getHeaders [3][4] - hasHeader($name) [1][2] - getHeader($name): array [1][2] - getHeaderLine($name): string [2][1] - withHeader($name, $value) [2] - withAddedHeader($name, $value) [3][4] - withoutHeader($name) [3][4] - getBody [4] - withBody(StreamInterface $body) [4] - Response-specific: - getStatusCode [3][4] - withStatus($code, $reasonPhrase = '') [3][4] - getReasonPhrase [3][4]

Citations:


🏁 Script executed:

rg "Response" src/ApiConnector.php

Repository: Penneo/sdk-php

Length of output: 199


Return ResponseInterface, not the concrete Guzzle response class.

Client::request() is typed against PSR-7 ResponseInterface. Narrowing callServer() to ?Response hard-codes the implementation class and can trip a runtime TypeError if a different PSR-7 response object comes back through the handler stack. The instanceof Response check on line 189 is also unnecessary—ResponseInterface includes the getHeader() method.

Suggested fix
-use GuzzleHttp\Psr7\Response;
 use GuzzleHttp\RequestOptions;
 use Penneo\SDK\OAuth\Config\Environment;
 use Penneo\SDK\OAuth\OAuth;
 use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
 use Psr\Log\LoggerInterface;
 use Psr\Log\NullLogger;
@@
     public static function callServer(
         string $url,
         $data = null,
         string $method = 'get',
         array $options = array()
-    ): ?Response {
+    ): ?ResponseInterface {
@@
-            if ($response instanceof Response) {
-                // some logging implementation might not print the context, we put the request id in the log message
-                // because it is important and we want to make sure it gets seen
-                self::$logger->debug(
-                    'response requestId=' . implode('', $response->getHeader('X-Penneo-Request-Id')),
-                    ['method' => $method, 'url' => $url]
-                );
-            }
+            // some logging implementation might not print the context, we put the request id in the log message
+            // because it is important and we want to make sure it gets seen
+            self::$logger->debug(
+                'response requestId=' . implode('', $response->getHeader('X-Penneo-Request-Id')),
+                ['method' => $method, 'url' => $url]
+            );
             return $response;

Also applies to: 189-197

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ApiConnector.php` around lines 162 - 167, Change the callServer signature
and internal handling to use the PSR-7 interface instead of the concrete Guzzle
class: update the return type of callServer(...) from ?Response to
?Psr\Http\Message\ResponseInterface, accept and return ResponseInterface from
Client::request(), and remove the redundant instanceof Response check (and any
Guzzle-specific type hints) around the response since ResponseInterface exposes
getHeader() and other needed methods; ensure proper "use
Psr\Http\Message\ResponseInterface" import is added.

try {
self::$logger->debug(
'request',
Expand Down Expand Up @@ -234,9 +253,9 @@ private static function fixEndpoint(string $uri): string
*
* @param mixed $data
*
* @return string|null
* @return false|null|string
*/
private static function sanitizeData($data): ?string
private static function sanitizeData($data)
{
if ($data !== null && !is_string($data)) {
$serializedData = json_encode($data);
Expand All @@ -251,7 +270,7 @@ private static function sanitizeData($data): ?string
return $data;
}

public static function initializeOAuth(OAuth $oauth, string $apiVersion = 'v3')
public static function initializeOAuth(OAuth $oauth, string $apiVersion = 'v3'): void
{
$handler = HandlerStack::create();
$handler->push($oauth->getMiddleware());
Expand Down
Loading
Loading