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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Changed
### Removed

## 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()`.

## 3.1.1 - 2026-05-05
### Added
- Removed nesbot/carbon dependency; OAuth timestamps use native `DateTimeImmutable` / `time()`.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ relevant `requestIds` you find in the logs.
The case file object is a container used to bundle documents and signers. Every signing process starts with a case
file.
* [Documents][document-docs]
The document object represents (and contains) the actual PDF document.
The document object represents the document file. Use `setFile()` for the upload path (PDF supported today; same API field as future formats). `setPdfFile()` remains for compatibility.
* [Signature lines][signature-line-docs]
Every signable document must have at least one signature line. Think of it as the dashed line that people used to sign
using a pen.
Expand Down Expand Up @@ -233,7 +233,7 @@ CaseFile::persist($myCaseFile);
// Create a new signable document in this case file
$myDocument = new Document($myCaseFile);
$myDocument->setTitle('Demo document');
$myDocument->setPdfFile('/path/to/pdfFile');
$myDocument->setFile('/path/to/document.pdf');
$myDocument->makeSignable();
Document::persist($myDocument);

Expand Down
36 changes: 31 additions & 5 deletions docs/document.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Documents
The document object represents (and contains) the actual PDF document. A document can either be a signable document or an unsignable _annex_. Note that document are always linked to a case file and can't exist on their own.
The document object represents (and contains) the document file. **Creating documents via the SDK currently supports PDF only** (signable document or annex). The API uses a generic `file` field; `setFile()` is the preferred way to supply the path, while `setPdfFile()` remains for compatibility.

A document can either be a signable document or an unsignable _annex_. Documents are always linked to a case file and can't exist on their own.

## Creating a document
Creating a document requires that you have a case file first since a document can't exist on its own. The case file must be passed to the _Document_ constructor and the document will be linked to the case file.
Expand All @@ -13,8 +15,8 @@ $myDocument = new Document($myCaseFile);
// Set the document title
$myDocument->setTitle('My brand new document');

// Add the actual PDF document
$myDocument->setPdfFile('/path/to/pdfFile');
// Add the PDF file (generic `file` field on the API)
$myDocument->setFile('/path/to/document.pdf');

// Make the document signable
$myDocument->makeSignable();
Expand All @@ -23,6 +25,8 @@ $myDocument->makeSignable();
Document::persist($myDocument);
```

> **Note:** `setPdfFile()` still works but is deprecated. Use `setFile()` for new code — same behaviour today (PDF), aligned with the API’s `file` parameter for future formats.

## Retrieve existing documents
There is several ways to retrieve document from Penneo. Available methods for retrieving documents are:

Expand Down Expand Up @@ -68,8 +72,28 @@ $myDocuments = Document::findBy(
);
```

## Retrieving the signed document
When the signing process is completed (when __getStatus()__ returns "completed"), the signed PDF document can be retrieved by calling the __getPdf()__ method on the document object in question.
## Downloading the document content
When the signing process is completed (when __getStatus()__ returns "completed"), the signed document can be downloaded by calling __getContent()__:

```php
// Download the signed document (binary)
$binary = $myDocument->getContent();
file_put_contents('signed-document.pdf', $binary);

// Download the unsigned version
$unsigned = $myDocument->getContent(false);

// Document format as returned by the API (typically "pdf" today)
$format = $myDocument->getFormat();
```

The `getContent()` method accepts one optional parameter:

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `$signed` | bool | `true` | Return the signed version when available |

> **Note:** `getPdf()` still works but is deprecated. Use `getContent()` for new code — raw binary from `/content` without base64 JSON.

## Retrieving linked objects
A signable document contains signature lines. These objects can be retrieved using the following methods:
Expand Down Expand Up @@ -98,5 +122,7 @@ Returns the date and time when the document was last modified as a _DateTime_ ob
Returns the date and time when the document signing process was finalized as a _DateTime_ object.
* __getDocumentId()__
Returns the unique ID that is stamped on every page in the document for identification purposes.
* __getFormat()__
Returns the document format as a string (typically `"pdf"`). Returns `null` for locally created documents that have not been persisted yet.
* __getOptions()__
Returns the option values assigned to the document.
10 changes: 10 additions & 0 deletions examples/casefile-e2e-demo.php
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,16 @@ function printStep(string $label): void
$link = $signingRequest->getLink();
echo "\nSigning link:\n{$link}\n";

printStep('Download document content (Document::getContent)');
try {
$binary = $document->getContent();
echo 'Downloaded document binary: ' . strlen($binary) . " bytes\n";
$format = $document->getFormat();
echo 'Document format: ' . ($format ?? 'n/a') . PHP_EOL;
} catch (Throwable $e) {
echo '(skip content download: ' . $e->getMessage() . ')' . PHP_EOL;
}

try {
$log = $signer->getEventLog();
echo 'Signer event log entries (LogEntry): ' . count($log) . PHP_EOL;
Expand Down
74 changes: 71 additions & 3 deletions src/Document.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class Document extends Entity
'options',
'type',
'@pdfFile',
'@file',
'documentTypeId' => 'documentType->getId'
),
'update' => array(
Expand All @@ -31,7 +32,9 @@ class Document extends Entity
protected $modified;
protected $completed;
protected $status;
protected $format;
protected $pdfFile;
protected $file;

protected $caseFile;
protected $type = 'attachment';
Expand Down Expand Up @@ -85,22 +88,87 @@ public function findSignatureLine($id)
return parent::findLinkedEntity($this, SignatureLine::class, $id);
}

public function getPdf()
/**
* Download the document content as raw binary via GET .../content.
*
* Upload is PDF-only in the API today; this returns whatever bytes the API stores for the document.
* Content is always returned decrypted (API default); the encrypted storage blob is not exposed via the SDK.
*
* @param bool $signed Get the signed version when available (default: true). Pass false for the original document.
*
* @return string Raw binary content
*/
public function getContent(bool $signed = true): string
{
$data = parent::getAssets($this, 'pdf');
return base64_decode($data[0]);
$params = [];
if (!$signed) {
$params['signed'] = 'false';
}

return parent::getBinaryContent($this, 'content', $params);
}

/**
* @deprecated Use getContent() instead.
*
* @param bool $signed Get the signed version when available (default: true). Pass false for the original document.
*
* @return string Raw binary PDF content
*/
public function getPdf(bool $signed = true): string
{
return $this->getContent($signed);
}

public function makeSignable()
{
$this->type = 'signable';
}

/**
* Set the document file from a local path. The file is base64-encoded and sent as the API `file`
* field (alongside the legacy `pdfFile` from setPdfFile()). Only PDF uploads are supported by
* the API today; this naming prepares for additional formats without breaking compatibility.
*
* @param string $filePath Path to the local PDF file
*/
public function setFile(string $filePath): void
{
$this->file = $filePath;
}
Comment on lines +135 to +138

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 | 🟡 Minor | ⚡ Quick win

Validate file readability in setFile() to fail fast.

On Line 138, an unreadable path is accepted and later silently omitted from request payload construction, which makes upload failures harder to diagnose.

Proposed fix
 public function setFile(string $filePath): void
 {
+    if (!is_readable($filePath)) {
+        throw new \InvalidArgumentException('Document file is not readable: ' . $filePath);
+    }
     $this->file = $filePath;
+    $this->pdfFile = null; // avoid sending both legacy and new file fields
 }
🤖 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/Document.php` around lines 136 - 139, The setFile method on the Document
class currently assigns an unreadable path to $this->file; change it to validate
the provided $filePath (using PHP functions like file_exists and is_readable)
and throw a clear exception (e.g., InvalidArgumentException) when the path is
missing or not readable so callers fail fast; update Document::setFile to
perform this check and only assign $this->file when the path passes validation,
ensuring later payload construction won't silently omit the file.


/**
* @deprecated Use setFile() instead.
*/
public function setPdfFile($pdfFile)
{
$this->pdfFile = $pdfFile;
}

/**
* Return the document format as reported by the API.
*
* In practice this is `"pdf"` for current integrations. Other numeric format codes from the API
* are mapped when present; additional format names may apply as the API evolves.
*
* @return string|null
*/
public function getFormat(): ?string
{
if ($this->format === null) {
return null;
}

$formats = [
1 => 'pdf',
2 => 'xml',
3 => 'xhtml',
4 => 'zip',
];

return $formats[$this->format] ?? (string) $this->format;
}

public function getDocumentId()
{
return $this->documentId;
Expand Down
29 changes: 29 additions & 0 deletions src/Entity.php
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,35 @@ public static function getAssets(Entity $parent, $assetName)
return $result;
}

/**
* Fetch raw binary content from an asset endpoint.
*
* Unlike getAssets(), the response body is returned as-is without JSON decoding.
* This requires that the server returns binary (i.e. the request must NOT include
* an Accept: application/json header, which is already the SDK default).
*
* @param Entity $parent
* @param string $assetPath Sub-path appended after the entity URL (e.g. "content")
* @param array $queryParams Associative array of query-string parameters
*
* @return string Raw binary content
* @throws Exception
*/
public static function getBinaryContent(Entity $parent, string $assetPath, array $queryParams = []): string
{
$url = $parent->getRelativeUrl() . '/' . $parent->getId() . '/' . $assetPath;
if ($queryParams) {
$url .= '?' . http_build_query($queryParams);
}

$response = ApiConnector::callServer($url);
if (!$response) {
throw new Exception('Penneo: Internal problem encountered fetching content: ' . $assetPath);
}

return $response->getBody()->getContents();
}

public static function callAction(Entity $parent, string $actionName, string $method = 'patch', $data = null): bool
{
$url = $parent->getRelativeUrl() . '/' . $parent->getId() . '/' . $actionName;
Expand Down
Loading