rishi-raj-jain / flysystem-neon
Flysystem adapter for Neon Object Storage (branchable, S3-compatible storage on Neon).
Requires
- php: ^8.2
- aws/aws-sdk-php: ^3.337
- league/flysystem: ^3.10
- league/flysystem-aws-s3-v3: ^3.10
Requires (Dev)
- league/flysystem-adapter-test-utilities: ^3.10
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A Flysystem v3 adapter for Neon Object Storage: S3-compatible object storage that branches along with your Neon Postgres database.
It is built on Flysystem's official AWS S3 adapter and adds what Neon needs:
- an S3 client preconfigured for Neon (path-style addressing, SigV4, checksums only when required);
- visibility that follows Neon's bucket-level access levels (
private/public_read), since Neon has no per-object ACLs; fromEnvironment(), which reads theAWS_*variables thatneon env pull,neon deployand Neon Functions provide.
Installation
composer require rishi-raj-jain/flysystem-neon
Requires PHP 8.2+ and league/flysystem 3.x.
Get credentials
You need a Neon project in a region with Object Storage (AWS us-east-1, us-east-2, eu-central-1 or
ap-southeast-1), a bucket, and a credential with the storage:read and storage:write scopes.
neon buckets create uploads # add --access-level public_read for a public bucket neon env pull --file .env # writes the AWS_* variables below
AWS_ENDPOINT_URL_S3=https://br-cool-darkness-a1b2c3d4.storage.c-1.us-east-2.aws.neon.tech AWS_ACCESS_KEY_ID=nak_live_... AWS_SECRET_ACCESS_KEY=nsk_live_... AWS_REGION=us-east-2
You can also find these values under Connect > Storage in the Neon Console. See Get started with Object Storage and Authentication.
Usage
use League\Flysystem\Filesystem; use RishiRajJain\FlysystemNeon\NeonStorageAdapter; // Reads AWS_ENDPOINT_URL_S3, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_REGION. $adapter = NeonStorageAdapter::fromEnvironment(bucket: 'uploads'); $filesystem = new Filesystem($adapter); $filesystem->write('avatars/rishi.txt', 'Hello from Neon!'); echo $filesystem->read('avatars/rishi.txt'); foreach ($filesystem->listContents('avatars', deep: true) as $item) { echo $item->path(), PHP_EOL; } // Presigned download link for a private bucket. $url = $filesystem->temporaryUrl('avatars/rishi.txt', new DateTimeImmutable('+10 minutes'));
Passing configuration explicitly
$adapter = NeonStorageAdapter::fromConfig([ 'endpoint' => 'https://br-cool-darkness-a1b2c3d4.storage.c-1.us-east-2.aws.neon.tech', 'key' => 'nak_live_...', 'secret' => 'nsk_live_...', 'region' => 'us-east-2', // optional, defaults to us-east-2 'bucket' => 'uploads', 'prefix' => 'tenant-42', // optional, scopes every path ("root" also works) 'visibility' => 'private', // the bucket's access level: "private" or "public" 'client_options' => [], // optional, extra Aws\S3\S3Client options ]);
Or bring your own client:
$client = NeonStorageAdapter::createClient($endpoint, $accessKeyId, $secretAccessKey, 'us-east-2'); $adapter = new NeonStorageAdapter($client, 'uploads', prefix: 'tenant-42');
Visibility
Neon sets access per bucket, not per object. A bucket is either private or public_read, and you
choose this when you create it in the Console, API or CLI. Tell the adapter which one it is talking to:
$public = NeonStorageAdapter::fromEnvironment('public-assets', bucketVisibility: 'public');
The adapter then behaves like this:
| Operation | Behavior |
|---|---|
write() / writeStream() with any visibility |
Succeeds. The object takes the bucket's access level. |
visibility() |
Returns the bucket's access level. |
setVisibility() with the bucket's level |
No-op. |
setVisibility() with the other level |
Throws UnableToSetVisibility instead of silently doing nothing. |
publicUrl() on a public_read bucket |
Path-style URL that anyone can read. |
publicUrl() on a private bucket |
Throws UnableToGeneratePublicUrl. Use temporaryUrl() instead. |
To serve some files publicly and keep others private, use two buckets (and two adapters, or Flysystem's
MountManager).
Branches
Each Neon branch has its own storage endpoint, and a new branch starts with a copy-on-write view of its parent's objects. Point the adapter at a branch's endpoint to work on that branch:
$preview = NeonStorageAdapter::fromConfig([ 'endpoint' => 'https://br-preview-xyz.storage.c-1.us-east-2.aws.neon.tech', // the preview branch 'key' => getenv('AWS_ACCESS_KEY_ID'), // a credential from main also works on child branches 'secret' => getenv('AWS_SECRET_ACCESS_KEY'), 'bucket' => 'uploads', ]);
Files written on the preview branch don't show up on main, and changes on the preview don't affect
the parent's objects. That makes preview environments and test runs with real uploads safe.
Get a branch's endpoint with neon env pull while linked to that branch, or from the
GET /projects/{project_id}/branches/{branch_id}/storage API.
Why not just use the AWS S3 adapter?
Neon speaks the S3 API, so league/flysystem-aws-s3-v3 mostly works once it's configured with the right
endpoint and use_path_style_endpoint. These parts don't, verified against a live Neon branch:
| With the plain S3 adapter | With this adapter |
|---|---|
write(..., ['visibility' => 'public']) fails with 501 NotImplemented (public canned ACLs aren't supported) |
Write succeeds |
setVisibility() always fails with 501 (PutObjectAcl isn't supported) |
No-op or a clear error |
visibility() reports private even for objects in a public_read bucket |
Reports the bucket's access level |
Leaving out use_path_style_endpoint breaks every request |
Preconfigured |
An ACL option in the write config is forwarded and rejected |
Not forwarded |
All other S3 adapter features keep working: streaming reads, multipart uploads for large files,
ContentType / CacheControl / ContentDisposition / Metadata options, server-side copy and move,
deep listings, presigned temporary URLs and ETag checksums.
Testing
The adapter runs Flysystem's shared adapter test suite
(league/flysystem-adapter-test-utilities) against two live Neon buckets, one private and one
public_read. Each run writes under a random prefix and cleans up after itself.
composer install vendor/bin/phpunit --testsuite unit # no network needed cp .env.example .env # fill in a branch's credentials and two bucket names vendor/bin/phpunit --testsuite integration # runs against Neon vendor/bin/phpstan analyse
A few upstream scenarios assume per-object ACLs or "no mime type". The test classes override those to assert Neon's actual behavior; each override has a comment explaining it.
License
MIT. See LICENSE.