From 5d0c63f761dbbe9c66a0d59c0355bc5ad6e4c537 Mon Sep 17 00:00:00 2001 From: Mohamed Abdulalim Date: Thu, 12 Nov 2026 23:54:41 +0200 Subject: [PATCH 1/3] Close pre-RC documentation compliance findings --- PHP_PERSISTENCE_PACKAGE_REFERENCE.md | 9 +++++++++ docs/architecture/ARCHITECTURE.md | 2 +- src/Pagination/DTO/PageResultDTO.php | 13 ++++++++++++- src/Pagination/Repository/PdoPaginator.php | 18 ++++++++++++++++++ src/Pagination/ValueObject/SortWhitelist.php | 16 ++++++++++++++++ .../SavepointTransactionRunnerInterface.php | 4 ++++ .../Repository/TransactionRunnerInterface.php | 6 ++++++ 7 files changed, 66 insertions(+), 2 deletions(-) diff --git a/PHP_PERSISTENCE_PACKAGE_REFERENCE.md b/PHP_PERSISTENCE_PACKAGE_REFERENCE.md index 365f02d..6f2f6fc 100644 --- a/PHP_PERSISTENCE_PACKAGE_REFERENCE.md +++ b/PHP_PERSISTENCE_PACKAGE_REFERENCE.md @@ -51,6 +51,15 @@ The [README](README.md#installation) records the exact installation target and t ## 2. Source topology +```text +Source Topology: Multi Capability + +Capabilities: +- Ordering +- Pagination +- Transaction +``` + One Composer package, three independent capability folders under `src/`, per [DEC-010](docs/decisions/DEC-010-package-topology-multi-capability.md) and [DEC-011](docs/decisions/DEC-011-responsibility-placement-and-savepoint-helper.md). Dependency direction: `Ordering → Transaction`; `Pagination` and `Transaction` do not depend on each other or on `Ordering`. ```text diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 83b0cf4..05cb7ff 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -143,7 +143,7 @@ Each work unit also re-verifies the legacy source commit ([DEC-007](../decisions | Item | Record | Blocks | |---|---|---| -| Safe MariaDB contract for `1020` in caller-owned transactions | [DEC-009](../decisions/DEC-009-proposed-version-matrix-and-mariadb-snapshot-isolation.md) `PROPOSED` | WU-8 acceptance for MariaDB; any MariaDB claim | +| Safe MariaDB contract for `1020` in caller-owned transactions | [DEC-009](../decisions/DEC-009-proposed-version-matrix-and-mariadb-snapshot-isolation.md) `PROPOSED` | An approved recovery contract for MariaDB error `1020`. WU-8 is complete, and DEC-013 remains a test-only matrix. | | Which two independent Hosts validate the release candidate, and when | none yet | WU-13 | | Central standards change, consumer migration, legacy archive | none (separate authorizations) | Any statement that ownership transferred | diff --git a/src/Pagination/DTO/PageResultDTO.php b/src/Pagination/DTO/PageResultDTO.php index 8da9375..d41981b 100644 --- a/src/Pagination/DTO/PageResultDTO.php +++ b/src/Pagination/DTO/PageResultDTO.php @@ -9,6 +9,10 @@ use Maatify\PhpPersistence\Pagination\Exception\PaginationExecutionException; /** + * Immutable mapped page and navigation metadata shared by the array and JSON + * representations. Counts come from separate reads and can differ under + * concurrent writes. + * * @template T of array|object */ final readonly class PageResultDTO implements JsonSerializable @@ -16,7 +20,14 @@ private const PUBLIC_KEY_PATTERN = '/^[A-Za-z_][A-Za-z0-9_]*$/'; /** - * @param list $data + * Validates a one-based page with a positive size, non-negative counts, + * and at most one page of mapped items. The page count and navigation + * flags must agree with the filtered count and page size. A zero filtered + * count requires empty data, page one, and no navigation. + * + * @param list $data Mapped items in page order. + * + * @throws PaginationExecutionException When the result is inconsistent. */ public function __construct( public array $data, diff --git a/src/Pagination/Repository/PdoPaginator.php b/src/Pagination/Repository/PdoPaginator.php index 9536a9f..06502ba 100644 --- a/src/Pagination/Repository/PdoPaginator.php +++ b/src/Pagination/Repository/PdoPaginator.php @@ -13,6 +13,13 @@ use PDO; use PDOStatement; +/** + * Paginates caller-supplied SQL on a caller-owned PDO connection. + * + * Request values select bounded page sizes and whitelisted sort identifiers. + * Total, filtered count, and data are separate reads. The caller controls + * transaction ownership and any shared snapshot. + */ final readonly class PdoPaginator { private const PUBLIC_KEY_PATTERN = '/^[A-Za-z_][A-Za-z0-9_]*$/'; @@ -21,6 +28,17 @@ private const OFFSET_PARAMETER = '__pagination_offset'; /** + * Normalizes the request against the configuration, reads both counts, and + * maps rows from the sorted, bounded data query. The descriptor supplies + * trusted SQL and filter parameters. Zero filtered rows skip the data query + * and return an empty first page. A page above the last page resets to one. + * + * Leaves the caller's transaction and isolation level intact. PDO and + * mapper exceptions propagate unchanged. + * + * @throws PaginationExecutionException When a statement fails without throwing + * or a count, row, or mapped item is invalid. + * * @template TItem of array|object * * @param callable(array): TItem $mapper diff --git a/src/Pagination/ValueObject/SortWhitelist.php b/src/Pagination/ValueObject/SortWhitelist.php index d7eeb23..ebdcb47 100644 --- a/src/Pagination/ValueObject/SortWhitelist.php +++ b/src/Pagination/ValueObject/SortWhitelist.php @@ -6,6 +6,12 @@ use Maatify\PhpPersistence\Pagination\Exception\InvalidPaginationConfigurationException; +/** + * Maps public sort keys to validated, quoted SQL identifier paths. + * + * The caller defines the mapping from trusted identifiers. A request can + * select a key from it but cannot supply the SQL identifier. + */ final readonly class SortWhitelist { private const IDENTIFIER_PATTERN = '/^[A-Za-z_][A-Za-z0-9_]*$/'; @@ -14,7 +20,13 @@ private array $quotedSorts; /** + * Accepts a non-empty map of public keys to identifiers with one to three + * dot-separated segments. Each key and segment starts with an ASCII letter + * or underscore, followed by ASCII letters, digits, or underscores. + * * @param non-empty-array $sorts + * + * @throws InvalidPaginationConfigurationException When the mapping is empty or invalid. */ public function __construct(array $sorts) { @@ -27,7 +39,11 @@ public function contains(string $key): bool } /** + * Returns the SQL identifier for an allowed key, with each segment quoted. + * * @return non-empty-string + * + * @throws InvalidPaginationConfigurationException When the key is absent. */ public function quotedIdentifierFor(string $key): string { diff --git a/src/Transaction/Repository/SavepointTransactionRunnerInterface.php b/src/Transaction/Repository/SavepointTransactionRunnerInterface.php index 57f7a36..fa9b57f 100644 --- a/src/Transaction/Repository/SavepointTransactionRunnerInterface.php +++ b/src/Transaction/Repository/SavepointTransactionRunnerInterface.php @@ -4,4 +4,8 @@ namespace Maatify\PhpPersistence\Transaction\Repository; +/** + * Runs a callback within an operation-local savepoint when joining an active + * caller-owned transaction. The caller retains control of the outer transaction. + */ interface SavepointTransactionRunnerInterface extends TransactionRunnerInterface {} diff --git a/src/Transaction/Repository/TransactionRunnerInterface.php b/src/Transaction/Repository/TransactionRunnerInterface.php index 82e0ebd..0d36c46 100644 --- a/src/Transaction/Repository/TransactionRunnerInterface.php +++ b/src/Transaction/Repository/TransactionRunnerInterface.php @@ -4,9 +4,15 @@ namespace Maatify\PhpPersistence\Transaction\Repository; +/** + * Executes a callback with the transaction ownership rules of the implementation. + */ interface TransactionRunnerInterface { /** + * Returns the callback result after successful execution. When a callback + * failure escapes, implementations propagate the original throwable. + * * @template TResult * * @param callable(): TResult $callback From b5c77390682b3ae42bb8c8ffee06510785dd0c2c Mon Sep 17 00:00:00 2001 From: Mohamed Abdulalim Date: Fri, 13 Nov 2026 00:21:45 +0200 Subject: [PATCH 2/3] =?UTF-8?q?Match=20the=20README=20logo=20to=20the=20re?= =?UTF-8?q?ader=E2=80=99s=20color=20scheme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 929f1ce..a6f962f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,11 @@ # PHP Persistence -![Maatify.dev](https://www.maatify.dev/assets/img/img/maatify_logo_white.svg) + + + + Maatify.dev + [![Status at preparation](https://img.shields.io/badge/Status%20at%20preparation-Development-blue)](README.md#overview) [![PHP](https://img.shields.io/badge/PHP-%5E8.4-8892BF)](composer.json) @@ -28,7 +32,7 @@ Direct-PDO Transaction, Pagination, and Ordering utilities for MySQL and MariaDB `maatify/php-persistence` is a small, direct-PDO toolkit with three independent capabilities over a **caller-supplied** `PDO` connection: running callbacks inside a transaction or a caller's savepoint (with optional bounded deadlock replay), parameterized whitelist-sorted bounded pagination, and locking next-position/move operations for integer display-order columns. It is developed as a separate package from the earlier, published `maatify/persistence` ([coexistence rules](docs/decisions/DEC-005-coexistence-with-legacy-package.md)), not a drop-in replacement. -This tree prepares the **Release Candidate artifact `1.0.0-rc.1`**. The live project is in **Development** while the target is unpublished. The publication state recorded for this preparation is **Unpublished**. +This tree is prepared as the **Release Candidate artifact `1.0.0-rc.1`**. Its **publication state at qualification** is **Unpublished**. ## Key Features From d9a5dbc4ddb25fb82846df0023bb15118ba7aa2a Mon Sep 17 00:00:00 2001 From: Mohamed Abdulalim Date: Fri, 13 Nov 2026 00:32:40 +0200 Subject: [PATCH 3/3] Close the remaining release candidate standards findings --- composer.json | 3 +- ...n-matrix-and-mariadb-snapshot-isolation.md | 8 +- docs/guides/RELEASE_ARTIFACT_VERIFICATION.md | 10 +- scripts/published-artifact.php | 207 ++++++++++++++++++ src/Pagination/Criteria/PageRequest.php | 4 + 5 files changed, 225 insertions(+), 7 deletions(-) create mode 100644 scripts/published-artifact.php diff --git a/composer.json b/composer.json index 428373c..97c6520 100644 --- a/composer.json +++ b/composer.json @@ -70,7 +70,8 @@ "test:integration": "@php scripts/integration.php", "examples:smoke": "@php scripts/examples-smoke.php", "test:consumer": "@php scripts/consumer-verification.php", - "release:verify": "@php scripts/release-artifact.php" + "release:verify": "@php scripts/release-artifact.php", + "release:verify-published": "@php scripts/published-artifact.php" }, "config": { "optimize-autoloader": true, diff --git a/docs/decisions/DEC-009-proposed-version-matrix-and-mariadb-snapshot-isolation.md b/docs/decisions/DEC-009-proposed-version-matrix-and-mariadb-snapshot-isolation.md index c59f77b..2406290 100644 --- a/docs/decisions/DEC-009-proposed-version-matrix-and-mariadb-snapshot-isolation.md +++ b/docs/decisions/DEC-009-proposed-version-matrix-and-mariadb-snapshot-isolation.md @@ -27,13 +27,11 @@ The version-matrix rows that this record first proposed (endpoints, intermediate 4. The package does not change `innodb_snapshot_isolation`, the isolation level, or any connection attribute. 5. If an honest and safe contract cannot be demonstrated for caller-owned transactions, MariaDB support is not advertised unconditionally; the claim is narrowed or withheld. -## Open decisions and what they block +## Deferred contract and WU-8 evidence -| Open decision | Must be settled before | -|---|---| -| Whether the safe `1020` contract in item 5 is achievable | WU-8 (Ordering) acceptance for MariaDB, and any MariaDB claim | +At proposal time, Ordering acceptance for MariaDB and broader MariaDB claims were listed as pending. WU-8 later completed with test-only evidence while the Owner kept the recovery contract deferred. -Observed engine facts that inform it (defaults of `innodb_snapshot_isolation` per version, error `1020` raised with the setting `ON`) are in [CI_AND_INTEGRATION.md](../architecture/CI_AND_INTEGRATION.md); what the server does after `1020` is still not established. +In the cases exercised by the [WU-8 Ordering integration test](https://github.com/Maatify-ltd/php-persistence/blob/2212fb0a74e8f44c82b2c508f3585e2b87ed5a8c/tests/Integration/Ordering/ScopedOrderingManagerMariaDbSnapshotIsolationHazardTest.php), the server rolls back a prior caller-owned write after error `1020`. The test also attempts a subsequent statement on the same connection and observes whether it is rejected or commits independently. [DEC-013](./DEC-013-test-only-database-version-boundary.md) governs the tested matrix. A general recovery guarantee or supported range remains outside the accepted contract. ## Consequences diff --git a/docs/guides/RELEASE_ARTIFACT_VERIFICATION.md b/docs/guides/RELEASE_ARTIFACT_VERIFICATION.md index 0a72a74..3cf3167 100644 --- a/docs/guides/RELEASE_ARTIFACT_VERIFICATION.md +++ b/docs/guides/RELEASE_ARTIFACT_VERIFICATION.md @@ -12,4 +12,12 @@ The verifier creates an isolated checkout of the selected commit. It generates a In pull-request CI the archive job checks out and verifies the exact PR HEAD. On a push it verifies the exact pushed commit. Qualification requires Full Applicable CI and Release Artifact Verification on the same actual, Owner-authorized `main` commit and exact target. The [Consumer Verification Harness](CONSUMER_VERIFICATION.md) separately exercises runtime consumption. -After separately authorized publication, the approved Composer channel provides the version and distribution evidence for Published Artifact Verification. Its archive and install mode must be observed there. This preparation step makes no assumption about a source-only distribution mode. +## Published Artifact Verification + +After authorized publication makes the exact version available through Packagist, run the following from a repository checkout containing the exact qualified commit and its existing `v1.0.0-rc.1` release tag. Set `QUALIFIED_SHA` to the full SHA from the recorded qualification evidence. + +```sh +composer release:verify-published -- --target=1.0.0-rc.1 --sha="$QUALIFIED_SHA" +``` + +The command uses a fresh external Composer root, home, and cache. It resolves the exact version through Packagist, checks the GitHub dist and source references against the qualified SHA, and requires Composer's installed metadata to report an actual `dist` installation. It compares the complete installed file inventory and bytes with a Git archive of the qualified commit, and logs version, installation mode, installed path, resolved metadata, and file hashes before cleaning up. Save the output with the release evidence. A missing version or dist, a source fallback, or any content difference fails verification. The command is read-only with respect to GitHub and Packagist. It is separate from the Consumer Verification Harness. diff --git a/scripts/published-artifact.php b/scripts/published-artifact.php new file mode 100644 index 0000000..52653c8 --- /dev/null +++ b/scripts/published-artifact.php @@ -0,0 +1,207 @@ + $command */ +function publishedCommand(array $command, string $directory): string +{ + [$status, $output, $errors] = execute($command, [], $directory, 180); + if ($status !== 0) { + throw new RuntimeException(sprintf('%s failed (%d): %s', $command[0], $status, trim($errors))); + } + + return trim($output); +} + +/** @param list $arguments */ +function publishedComposer(array $arguments, string $workspace): string +{ + return publishedCommand([ + 'env', '-i', 'PATH=' . (getenv('PATH') ?: '/usr/bin:/bin'), 'HOME=' . $workspace, + 'COMPOSER_HOME=' . $workspace . '/composer-home', + 'COMPOSER_CACHE_DIR=' . $workspace . '/composer-cache', + 'COMPOSER_NO_INTERACTION=1', 'GIT_CONFIG_NOSYSTEM=1', + 'composer', '--no-interaction', '--no-plugins', '--no-scripts', ...$arguments, + ], $workspace); +} + +/** @return array */ +function publishedJson(string $json): array +{ + $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($data) || array_is_list($data)) { + throw new RuntimeException('Expected a JSON object in Composer metadata'); + } + + return $data; +} + +function publishedRead(string $path): string +{ + if (!is_file($path) || is_link($path) || ($contents = file_get_contents($path)) === false) { + throw new RuntimeException('Cannot read required file: ' . $path); + } + + return $contents; +} + +function publishedVersion(mixed $version): bool +{ + // Composer can normalize the separator before the RC number in registry metadata. + return is_string($version) && preg_match('/^v?1\.0\.0-rc\.?1$/iD', $version) === 1; +} + +/** @return array */ +function publishedInventory(string $directory): array +{ + if (!is_dir($directory) || is_link($directory)) { + throw new RuntimeException('Expected an installed or reference directory'); + } + $files = []; + $entries = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, + ); + foreach ($entries as $entry) { + if (!$entry instanceof SplFileInfo) { + throw new RuntimeException('Cannot read artifact directory entry'); + } + $path = $entry->getPathname(); + $relative = substr($path, strlen($directory) + 1); + if ($entry->isLink() || (!$entry->isDir() && !$entry->isFile())) { + throw new RuntimeException('Unexpected artifact entry: ' . $relative); + } + if ($entry->isFile()) { + $files[$relative] = hash('sha256', publishedRead($path)); + } + } + ksort($files); + + return $files; +} + +/** @param array $metadata */ +function publishedReference(array $metadata, string $sha): void +{ + $source = $metadata['source'] ?? null; + $dist = $metadata['dist'] ?? null; + if (!is_array($dist) || ($dist['type'] ?? null) !== 'zip' + || ($dist['url'] ?? null) !== 'https://api.github.com/repos/' . PAV_REPOSITORY . '/zipball/' . $sha + || ($dist['reference'] ?? null) !== $sha + || !is_array($source) || ($source['type'] ?? null) !== 'git' + || ($source['url'] ?? null) !== 'https://github.com/' . PAV_REPOSITORY . '.git' + || ($source['reference'] ?? null) !== $sha + ) { + throw new RuntimeException('Packagist must expose GitHub dist and source for the exact qualified SHA'); + } +} + +$arguments = $_SERVER['argv'] ?? []; +if (!is_array($arguments) || count($arguments) !== 3 || ($arguments[1] ?? null) !== '--target=' . PAV_TARGET + || !is_string($arguments[2] ?? null) || preg_match('/^--sha=([0-9a-f]{40})$/D', $arguments[2], $matches) !== 1 +) { + fail(EXIT_USAGE, 'Usage: composer release:verify-published -- --target=' . PAV_TARGET . ' --sha='); +} + +$sha = $matches[1]; +$root = dirname(__DIR__); +$workspace = sys_get_temp_dir() . '/pp-published-' . bin2hex(random_bytes(12)); +if (!mkdir($workspace, 0700)) { + fail(EXIT_INFRASTRUCTURE, 'Cannot create published verification workspace'); +} + +$status = EXIT_TEST_FAILURE; +try { + $tag = 'refs/tags/v' . PAV_TARGET; + if (publishedCommand(['git', 'rev-parse', '--verify', $tag . '^{commit}'], $root) !== $sha) { + throw new RuntimeException('The release tag must resolve to the exact qualified SHA'); + } + $manifest = json_encode([ + 'require' => [PAV_PACKAGE => PAV_TARGET], + 'repositories' => ['packagist.org' => ['type' => 'composer', 'url' => 'https://repo.packagist.org']], + 'config' => ['preferred-install' => 'dist', 'allow-plugins' => false], + ], JSON_THROW_ON_ERROR); + if (file_put_contents($workspace . '/composer.json', $manifest) !== strlen($manifest)) { + throw new RuntimeException('Cannot create isolated consumer manifest'); + } + + echo publishedComposer(['--version'], $workspace) . "\n"; + echo "PAV source=https://repo.packagist.org preference=dist plugins=off scripts=off isolated-home-and-cache=true\n"; + $metadata = publishedJson(publishedComposer(['show', '--all', PAV_PACKAGE, PAV_TARGET, '--format=json'], $workspace)); + $versions = $metadata['versions'] ?? null; + if (($metadata['name'] ?? null) !== PAV_PACKAGE || !is_array($versions) || count($versions) !== 1 + || !publishedVersion($versions[0] ?? null) + ) { + throw new RuntimeException('Packagist did not resolve the exact package and release version'); + } + publishedReference($metadata, $sha); + + publishedComposer(['update', '--prefer-dist', '--no-progress'], $workspace); + $installation = publishedJson(publishedRead($workspace . '/vendor/composer/installed.json')); + $packages = $installation['packages'] ?? null; + if (!is_array($packages)) { + throw new RuntimeException('Composer did not record installed packages'); + } + $matches = array_values(array_filter($packages, static fn(mixed $package): bool + => is_array($package) && ($package['name'] ?? null) === PAV_PACKAGE)); + $package = $matches[0] ?? null; + if (count($matches) !== 1 || !is_array($package) || !publishedVersion($package['version'] ?? null) + || ($package['installation-source'] ?? null) !== 'dist' + || ($package['install-path'] ?? null) !== '../' . PAV_PACKAGE + ) { + throw new RuntimeException('Composer did not install the exact release from dist at the expected path'); + } + publishedReference($package, $sha); + + $reference = $workspace . '/qualified'; + $archive = $workspace . '/qualified.tar'; + if (!mkdir($reference, 0700)) { + throw new RuntimeException('Cannot create reference directory'); + } + publishedCommand(['git', 'archive', '--format=tar', '--output=' . $archive, $sha], $root); + publishedCommand(['tar', '-xf', $archive, '-C', $reference], $workspace); + $installed = $workspace . '/vendor/' . PAV_PACKAGE; + $expectedFiles = publishedInventory($reference); + $installedFiles = publishedInventory($installed); + foreach (['composer.json', 'README.md', 'CHANGELOG.md', 'SECURITY.md', 'LICENSE', + 'PHP_PERSISTENCE_PACKAGE_REFERENCE.md', 'docs/guides/USAGE_GUIDE.md', 'llms.txt', + 'examples/_bootstrap.php', 'examples/transaction_basic_usage.php', 'examples/pagination_basic_usage.php', + 'examples/ordering_basic_usage.php', + ] as $required) { + if (!isset($expectedFiles[$required])) { + throw new RuntimeException('The qualified reference omits a required file: ' . $required); + } + } + $runtime = publishedCommand(['git', 'ls-tree', '-r', '--name-only', $sha, '--', 'src'], $root); + if ($runtime === '' || array_diff(explode("\n", $runtime), array_keys($expectedFiles)) !== []) { + throw new RuntimeException('The qualified reference omits runtime source'); + } + if ($installedFiles !== $expectedFiles) { + $missingOrChanged = array_diff_assoc($expectedFiles, $installedFiles); + $extra = array_diff_key($installedFiles, $expectedFiles); + throw new RuntimeException('Installed dist differs from the qualified archive: ' + . implode(', ', [...array_keys($missingOrChanged), ...array_keys($extra)])); + } + echo 'PAV EVIDENCE ' . json_encode([ + 'package' => PAV_PACKAGE, 'target' => PAV_TARGET, 'sha' => $sha, 'tag' => $tag, + 'installedVersion' => $package['version'], 'installationSource' => 'dist', 'installedPath' => $installed, + 'dist' => $package['dist'], 'source' => $package['source'], 'files' => $installedFiles, + ], JSON_THROW_ON_ERROR) . "\n"; + $status = 0; +} catch (Throwable $exception) { + fwrite(STDERR, 'Published artifact verification failed: ' . $exception->getMessage() . "\n"); +} finally { + [$cleanup] = execute(['rm', '-rf', '--', $workspace]); + if ($cleanup !== 0 || file_exists($workspace)) { + fwrite(STDERR, "Published verification workspace cleanup failed\n"); + $status = EXIT_INFRASTRUCTURE; + } +} + +exit($status); diff --git a/src/Pagination/Criteria/PageRequest.php b/src/Pagination/Criteria/PageRequest.php index f9ce197..5628817 100644 --- a/src/Pagination/Criteria/PageRequest.php +++ b/src/Pagination/Criteria/PageRequest.php @@ -14,6 +14,10 @@ */ final readonly class PageRequest { + /** + * Store request values unchanged. Null leaves the corresponding page, + * page size, sort key, or sort direction unspecified for the paginator. + */ public function __construct( public int|string|null $page = null, public int|string|null $perPage = null,