feat(storage)!: 3.0 — immutable coroutine-safe devices, enums, max static analysis#78
Merged
Conversation
…nalysis Prepare the 3.0 major release of utopia-php/storage: - Remove the global device registry (Storage::setDevice/getDevice); Storage now only holds the human() formatting helper - Replace every setter (setTelemetry, setHttpVersion, setTransferChunkSize, static S3::setRetryAttempts/setRetryDelay) with readonly constructor arguments; transfer() takes the chunk size per call instead - Make the S3 adapter coroutine-safe: request headers are built per call and passed through call()/getSignatureV4() instead of mutating shared instance state; retries now reset the response buffer instead of appending to it - Replace string constants with enums: Storage::DEVICE_* becomes the DeviceType enum returned by getType(), S3::ACL_* becomes the Acl enum - Type the S3 response as a readonly value object with honest narrowing of decoded XML instead of stdClass juggling - Raise static analysis to the maximum: package-level phpstan.neon at level max and rector.php with all stable prepared sets, with src and tests fully conforming - Mark secret keys #[\SensitiveParameter] and add declare(strict_types=1) across the package Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
Greptile SummaryThis PR modernizes the storage package for the next major release. The main changes are:
Confidence Score: 5/5This looks safe to merge.
Important Files Changed
Reviews (13): Last reviewed commit: "feat(storage): typed exception hierarchy..." | Re-trigger Greptile |
Replace the raw cURL transport in the S3 adapter with any PSR-18 client, injected via the new `client` constructor argument and defaulting to utopia-php/client with the cURL adapter and no request timeout (matching the previous behavior for large chunk uploads). - Requests are built as PSR-7 messages (utopia-php/psr7) and signed exactly as sent; the S3-specific transient-error retry loop stays in call() since generic retry decorators cannot parse S3 XML error codes - Drop the `httpVersion` option and `HTTP_VERSION_*` constants: transport concerns (timeouts, TLS, HTTP version, connection reuse) now belong to the injected client - Add S3ClientTest covering signing, retry, exhaustion, NoSuchKey mapping, and XML decoding against a scripted PSR-18 stub Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the hand-rolled retry loop in S3::call() with the utopia-php/client Retry decorator driven by a new S3\RetryStrategy that encodes the S3-specific transient-error policy (SlowDown, ServiceUnavailable, Throttling, RequestThrottled XML codes, with plain 429/503 as fallback). - Drop the retryAttempts/retryDelay constructor arguments from S3 and its subclasses; the default client wraps the cURL adapter in Retry(new RetryStrategy()), and injecting a client now controls the whole retry policy (strategy, backoff, or none) - Remove S3::isTransientError() and the usleep loop; call() sends once - Rename S3SlowDownTest to S3MultipartTest (transient-error coverage moved to RetryStrategyTest); S3ClientTest's stub now implements the full client Adapter so retries are exercised through the real decorator Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simplification pass over the 3.0 surface, net -656 lines: - Delete the Storage class (human() included), the stub File validator, the unused getPath() $prefix parameter, the S3 METHOD_* constants (utopia-php/psr7 Method is used instead), and the unused utopia-php/system and ext-zlib dependencies - One telemetry mechanism: the Telemetry decorator. S3's internal storage.operation histogram and the telemetry constructor argument are removed, so wrapped devices are no longer double-counted - One upload flow: uploads are data-based. upload($sourcePath) is removed, uploadChunk() now takes chunk contents instead of a source file path, and uploadData() is a concrete Device method composing prepareUpload + uploadChunk + finalizeUpload (previously duplicated per adapter) - Filesystem-only methods (createDirectory, getDirectorySize, getPartitionFreeSpace, getPartitionTotalSpace, getFiles) leave the abstract Device contract; Local keeps them, S3 keeps its own getFiles, and the -1/no-op S3 stubs are deleted - getName()/getDescription() are removed everywhere; getType() identifies an adapter. abort()'s cryptic $extra is renamed $uploadId Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address review feedback: transfer() with a chunk size of zero crashed with a division by zero on large files, and a negative value silently returned true without writing the destination. Both Local and S3 now throw, and the runtime guard replaces the positive-int annotation as the contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI runs rector without a cache and flagged validator classes and tests that the locally cached run skipped: typed test properties, privatized final-class properties, and removed useless var tags. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Working checklist for creating or modernizing a package: immutable constructor-only state and coroutine safety, honest abstract surfaces, composition via decorators and PSR seams, enums for closed sets, max-level static analysis with runtime guards over annotations, the rector cache pitfall, test-tier contracts, and major-release hygiene. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Spell out parameter/dependencies shorthand in the skill and add ACLs, composable, docblock, and redeclaring to the shared vocabulary in their existing section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found migrating Appwrite and Appwrite Cloud: every device they inject is Telemetry-wrapped, so adapter-specific methods that left the base Device contract (Local partition metrics, S3 object listing) became unreachable through standard wiring. getUnderlying() gives consumers a typed path to the concrete adapter without widening the base contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
getFiles() was never one abstraction: Local returned path strings while S3 returned its decoded ListObjectsV2 array, leaking AWS quirks (the single-object Contents shape) to every consumer. listFiles(prefix, max, cursor) returns FileList/FileInfo readonly value objects consistently on both adapters and rejoins the base Device contract, so the Telemetry decorator forwards it and Device-typed consumers can paginate portably. The cursor is the S3 continuation token, or a numeric offset for Local. Also rename Telemetry::getUnderlying()/$underlying to getDevice()/ $device. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Unit tests now map one-to-one onto src/Storage: the two S3 unit test files merge into Device/S3Test.php, and the e2e suite (S3Base plus the MinIO-backed S3Test) moves to tests/E2E under its own namespace. The phpunit suites become directory-based, so new tests are picked up without editing phpunit.xml. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every runtime failure now extends Utopia\Storage\Exception\StorageException so consumers can catch storage problems as one category, or branch: - NotFoundException: missing files, now consistent on every adapter and method — S3 HEAD errors (empty body) previously surfaced as a generic exception with an empty message and Local stat/transfer failures were untyped - TransportException: PSR-18 delivery failures, previous chained - RemoteException: service error responses, with the HTTP status as the code and the service's own error identifier (e.g. SlowDown) as a readonly errorCode property; the message is the parsed S3 Message rather than the raw XML body - UploadException: invalid chunked-upload state (missing chunk, never prepared, finalize failure) Invalid arguments (non-positive chunk size, page size above the adapter limit, unknown validator mime type) throw SPL InvalidArgumentException: programmer errors, not storage failures. Existing catch (\Exception) blocks keep working. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Prepares the next major release of
utopia-php/storage(3.0.0 — latest tag isstorage/2.0.7), modernising the interface in line with newer utopia libraries such asdns.No global state
Storage::setDevice/getDevice/exists/$devices) is removed. Devices are constructed directly and passed around;Storageshrinks to the purehuman()helper.S3::setRetryAttempts()/setRetryDelay()are gone (see below).Immutability
readonlyconstructor promotion.setTelemetry(),setHttpVersion(), andsetTransferChunkSize()are replaced bytelemetry,httpVersion,retryAttempts,retryDelayconstructor arguments; transfer chunk size becomes a per-call argument:transfer($path, $dest, $device, $chunkSize).#[\SensitiveParameter].Coroutine safety
$this->headers/$this->amzHeaderson every operation — two concurrent requests on one device instance (e.g. Swoole coroutines) would race and corrupt each other's SigV4 signatures. Headers are now built locally per call and passed throughcall()/getSignatureV4(); a device instance holds no request state.Enums over string constants
Storage::DEVICE_*→Utopia\Storage\DeviceType(string-backed), returned byDevice::getType().S3::ACL_*→Utopia\Storage\Acl, taken as a typed constructor parameter.Maximum static analysis
packages/storage/phpstan.neonpins level max (root baseline is level 5);packages/storage/rector.phpextends the root baseline with the remaining stable prepared sets (typeDeclarationDocblocks,privatization,instanceOf,rectorPreset).srcandtestsfully conform: the S3 response is a typed readonly value object instead ofstdClass, decoded XML is honestly narrowed, the chunked-upload&$metadatacontract is a sharedUploadMetadataarray shape, and theTelemetrydecorator uses a generic closure-basedmeasure()instead of magic dispatch.Docs
listFiles/deleteDirectory→getFiles/deletePath).PSR-18 HTTP client
clientconstructor argument. The default is utopia-php/client with the cURL adapter and no request timeout (matching the old behavior for large chunk uploads); the Swoole coroutine adapter or any other PSR-18 client drops in.httpVersionoption andHTTP_VERSION_*constants are gone — transport concerns (timeouts, TLS, HTTP version, connection reuse) belong to the injected client.utopia-php/clientRetrydecorator with a newS3\RetryStrategythat encodes the S3-specific transient-error policy (SlowDown/ServiceUnavailable/Throttling/RequestThrottledXML codes, 429/503 fallback). The default client wraps the cURL adapter in it; injecting a client controls the whole retry policy, so theretryAttempts/retryDelayconstructor arguments are gone.S3ClientTestunit-tests signing, retries, retry exhaustion, NoSuchKey mapping, and XML decoding against a scripted PSR-18 stub.Simplification pass (net −656 lines)
Storageclass deleted entirely (registry was already gone;human()removed too). The stubFilevalidator, unusedgetPath()$prefix parameter,S3::METHOD_*constants (PSR-7Methodused instead), and the unusedutopia-php/system+ext-zlibdependencies are gone.Telemetrydecorator. S3 no longer has an internal histogram ortelemetryconstructor argument, so wrapped devices are not double-counted.upload($sourcePath)removed,uploadChunk()takes chunk contents, anduploadData()is now a concrete base-class method composingprepareUpload→uploadChunk→finalizeUpload(previously duplicated in each adapter).createDirectory,getDirectorySize,getPartitionFreeSpace/TotalSpace,getFiles) left the abstractDevicecontract —Localkeeps them,S3keeps its owngetFiles, the-1/no-op S3 stubs are deleted.getName()/getDescription()removed;getType()identifies adapters.abort($extra)renamedabort($uploadId).Consumer PoC feedback (Appwrite + Appwrite Cloud)
Telemetry::getDevice()added: both consumers wrap every injected device in the decorator, which made adapter-specific methods (Local partition metrics, S3 object listing) unreachable after the contract shrink. Consumers should inject the concrete adapter type where adapter-specific methods are needed (e.g. an S3-typed resource); the accessor is the escape hatch for call sites that receive an already-wrapped device.listFiles(prefix, max, cursor)replaces the inconsistentgetFiles()(path strings on Local vs rawListObjectsV2array on S3): typedFileList/FileInforeadonly value objects, identical on every adapter, back on the baseDevicecontract and forwarded by the decorator. The S3 single-objectContentsquirk is normalized inside the adapter instead of by every consumer.Typed exceptions
Utopia\Storage\Exception\StorageException:NotFoundException(missing files, now consistent across adapters and methods — S3 HEAD 404s previously threw a generic exception with an empty message),TransportException(PSR-18 delivery failures, previous chained),RemoteException(service errors; HTTP status as code, service error identifier such asSlowDownas a readonlyerrorCode), andUploadException(invalid chunked-upload state). Programmer errors throw SPL\InvalidArgumentException. Existingcatch (\Exception)blocks keep working.Test plan
bin/monorepo check storage— pint, PHPStan (level max), rector all passbin/monorepo test storage— 57 unit + 31 e2e tests pass; the e2e suite runs against a real MinIO container and exercises the rewritten per-request SigV4 signing, multipart, retry, and transfer paths end to endvale README.md docs— no errorsbin/monorepo validate— package conventions hold🤖 Generated with Claude Code