royvoetman/flysystem-gitlab-storage
A Flysystem adapter for storing files in GitLab repositories via the Repository Files API v4. Configure it with a project ID, branch, base URL, and personal access token, plus an optional path prefix. Works with Flysystem 3 and includes a Laravel integration.
1. Install (and pin the right major)
composer require royvoetman/flysystem-gitlab-storage:^3.1
The major version you need is dictated by your Flysystem version, not by the package's own maturity:
| Flysystem | Adapter | PHP floor |
|---|---|---|
| v3 (Laravel 9/10/11/12) | ^3.1 |
8.1 |
| v2 (Laravel 8-ish) | 2.0.4 |
7.4 |
v1 (league/flysystem 1.x) |
1.1.0 |
7.1 |
Pin ^3.1.1 at minimum — v3.1.1 replaced urlencode() with rawurlencode() because current GitLab rejects + in paths where a space should be. On 3.1.0, any filename containing a space breaks.
2. First run in plain PHP
use League\Flysystem\Filesystem;
use League\Flysystem\FilesystemException;
use RoyVoetman\FlysystemGitlab\Client;
use RoyVoetman\FlysystemGitlab\GitlabAdapter;
$client = new Client(
projectId: '12345678', // project front page, under the title
branch: 'main', // this is your "bucket"
baseUrl: 'https://gitlab.com', // your self-hosted host otherwise
token: env('GITLAB_TOKEN'), // PAT
);
$filesystem = new Filesystem(
new GitlabAdapter($client, 'path/prefix') // prefix is optional
);
$filesystem->write('exports/2024/report.csv', $csv);
$filesystem->fileExists('exports/2024/report.csv'); // true
$filesystem->read('exports/2024/report.csv');
$filesystem->listContents('exports', true);
3. Wire it into Laravel
The Laravel driver ships as a separate package, royvoetman/laravel-gitlab-storage. Install it and add a disk:
// config/filesystems.php
'gitlab' => [
'driver' => 'gitlab',
'project_id' => env('GITLAB_PROJECT_ID'),
'branch' => env('GITLAB_BRANCH', 'storage'),
'base_url' => env('GITLAB_BASE_URL', 'https://gitlab.com'),
'token' => env('GITLAB_TOKEN'),
'prefix' => env('GITLAB_PATH_PREFIX', 'storage/app'),
],
If the bridge lags your Laravel version, skip it and register the driver yourself — it's a handful of lines:
// AppServiceProvider::boot()
Storage::extend('gitlab', function ($app, $config) {
return new Filesystem(new GitlabAdapter(
new Client($config['project_id'], $config['branch'], $config['base_url'], $config['token']),
$config['prefix'] ?? '',
));
});
4. Pick your first use case deliberately
Good: build artifacts, generated exports, seeded fixtures, static documentation, compliance snapshots you want versioned. Bad: user uploads, sessions, caches, temp files. Every write() is a commit on branch; 500 uploads is 500 commits and a repo nobody wants to clone.
Where to look first: the GitLab Repository Files API docs (v4) — that is the adapter's behaviour surface. Then CHANGELOG.md, which is unusually informative about semantics (.gitkeep for createDir, blob → file type mapping, pagination added in 1.1.0, debug mode in 1.0.7).
Smoke test to run before anything else:
$fs->write('healthcheck/probe.txt', 'ok');
$fs->write('healthcheck/with space & ünïcode.txt', 'ok'); // catches the rawurlencode class of bug
dump($fs->read('healthcheck/probe.txt'));
dump(iterator_to_array($fs->listContents('healthcheck')));
$fs->delete('healthcheck/probe.txt');
If the unicode/space write throws, your pin is too old or GitLab rejected the encoded path.
Treat the branch as your isolation boundary. branch is a constructor argument and is the closest analogue to an S3 bucket. Use a dedicated storage branch (or a dedicated repo entirely) so that writes never contaminate the code branch and so git log on application code stays readable. Per-environment branches (storage-staging, storage-prod) give you a cheap environment split without new projects.
Wrap it in a small repository/service class. Never scatter Storage::disk('gitlab') calls. Flysystem's interface makes the backend swappable; a one-class seam keeps it that way.
final class ExportsRepository
{
public function __construct(private FilesystemOperator $fs) {}
public function put(string $key, string $contents): void
{
$this->fs->write($key, $contents);
}
}
Use the path prefix for tenant or domain scoping. new GitlabAdapter($client, "tenants/{$tenantId}") keeps listing calls narrow, which matters because listContents() is a paginated recursive tree walk over the repository.
Prefer a single large write to many small ones. Batching is the main lever on commit count, rate limits, and CI impact:
// Bad: N commits, N API calls, N chances to get a 429
foreach ($rows as $i => $row) {
$fs->write("exports/row-{$i}.json", json_encode($row));
}
// Better: one artifact per logical operation
$fs->write('exports/'.now()->format('Y-m-d').'/bundle.json', json_encode($rows));
If you genuinely need many files, zip them and write the archive, or write to a scratch location and consolidate.
Derive keys from content, not from user input. Because paths become Git paths, sanitise aggressively and add a hash:
$key = sprintf('uploads/%s/%s', date('Y/m'), hash('sha256', $originalName).'.bin');
This sidesteps encoding problems, collisions, and .gitkeep naming edges in one move.
Integrate with queued jobs, not request handlers. A write is an HTTP round trip plus a commit. Doing it synchronously in a controller adds GitLab's latency — and its failure modes — to your user's page load.
dispatch(new PublishExport($exportId));
Retry with exponential backoff; RetryUntil semantics on GitLab's 429 are the realistic failure you'll hit.
Use listContents() with a streaming iterator and cache the results. It's a paginated tree traversal, and the iterator re-requests as you consume it. Don't call it per row in a loop, and don't iterator_to_array() a large tree in a hot path without caching:
Cache::remember("gitlab:list:{$prefix}", 300, fn () =>
collect($fs->listContents($prefix, true))
->filter(fn ($a) => $a->isFile())
->map(fn ($a) => $a->path())
->all()
);
Read into streams for anything non-trivial. v2.0.2+ supports streamed reads, and v2.0.3 reuses the response stream rather than re-requesting. Use readStream() for downloads so you're not holding whole artifacts in memory:
$stream = $fs->readStream($path);
Storage::disk('local')->writeStream("tmp/{$name}", $stream);
Exploit the git-ness: the repository is an audit log. Every write is a commit with an author and timestamp. If you have a compliance or "who changed this artifact" requirement, this adapter gives it to you for free — set the PAT's owning account to a named service account so commits are attributable, and reference the commit SHA in your application's own metadata table if you need to correlate.
Laravel integration tips:
gitlab disk per logical dataset (e.g. gitlab-artifacts, gitlab-docs) with different prefixes rather than one giant disk.config/ only via env(); the adapter needs the raw token at construction, so Laravel's config:cache will bake it in — ensure your config cache isn't shipped anywhere.Storage::disk('gitlab')->url($path) will not work the way S3 does. There's no CDN; build GitLab web URLs yourself from base_url, project, branch, and path.This is not an object store. Every write() creates a commit. Directory creation is emulated with a .gitkeep file (since v1.0.0), so createDir() produces a real commit and listContents() will surface .gitkeep entries. Filter them out:
->filter(fn ($a) => $a->isFile() && !str_ends_with($a->path(), '.gitkeep'))
Unfiltered .gitkeep files show up in count(), allFiles(), and size calculations.
No locking, last-write-wins. Concurrent writes to the same branch produce divergent commits and one side gets a conflict error. There is no LOCK_EX. Wrap critical writes in a Laravel Cache::lock(), or route writes through a single queue worker per branch.
copy() and move() are likely read-then-write. The README doesn't advertise native support, so assume they fetch content and re-write it — two or three API calls, two commits, and a full transfer through your PHP process. For large files, prefer to delete() + write() from a stream you already have, or accept the cost knowingly.
Rate limits will bite before anything else does. GitLab's API limits apply per token. Batch writes, cache listings, and use a retry policy honouring Retry-After:
Http::retry(3, fn ($attempt) => 1000 * 2 ** $attempt, throw: false);
The adapter's own HTTP client is internal — if you need custom backoff, add it in your wrapper layer, not by subclassing the adapter.
The client constructor signature has changed subtly over time and v3.1.2 exists purely to fix an implicit-nullable deprecation on PHP 8.4+. If you're on PHP 8.4 and see deprecation noise, that pin is your fix. If you're constructing Client positionally, switch to named arguments — several parameters are optional and positional order is the classic source of "why is my token being used as a URL".
Debug mode exists (added 1.0.7) but is undocumented. Look for a flag on Client or the adapter in src/ — it will surface the underlying HTTP exchange. Reach for it first when you get inexplicable 4xx responses, especially on self-hosted GitLab that may have a path prefix or a slightly different API surface.
Self-hosted GitLab specifics. baseUrl must be the host root, not a project URL, and the API path is appended internally. If your instance sits behind a reverse proxy with a subpath (e.g. https://example.com/gitlab), verify against the API v4 endpoint directly before blaming the adapter. Also confirm your instance version still accepts the rawurlencode() form — very old instances have the opposite bug.
Pagination is real. v1.1.0 added support for paginated trees. Large prefixes will traverse multiple pages, each an HTTP call. If listContents() feels slow, that's why — not a bug.
Path encoding is the sharpest edge. Spaces, #, ?, %, and non-ASCII all travel through URL encoding into Git. v3.1.1 fixed the +-for-space regression, but the general advice stands: validate incoming filenames, use rawurlencode-safe characters, and hash long or exotic names. Write a regression test asserting a space-and-unicode path round-trips — this is the class of bug that only shows up in a customer's data.
PAT scope is a broad credential. A token that can write repository files can often do much more depending on scopes. Use a dedicated service account, api or write_repository scope only, store in a secrets manager, and have a rotation runbook. Nothing in this package manages credential lifecycle for you.
Version pinning is non-negotiable. 11 stars, 0 dependents, one maintainer. composer.lock plus an explicit ^3.1.2 constraint; be prepared to vendor or fork. The fork cost is low — it's a thin adapter — and knowing that up front is the difference between a calm incident and a fire.
No LFS. The adapter speaks the Repository Files API, not LFS endpoints. Multi-megabyte files land in the repository proper and bloat every clone and CI checkout. Enforce a size ceiling in your own code before the write, not after.
Extension point, if you need one: GitlabAdapter is final-ish in spirit but composed — you can wrap it in a decorator implementing FilesystemAdapter to add metrics, size guards, path sanitisation, or logging without forking:
final class GuardedAdapter implements FilesystemAdapter
{
public function __construct(private FilesystemAdapter $inner, private int $maxBytes) {}
public function write(string $path, string $contents, Config $config): void
{
if (strlen($contents) > $this->maxBytes) {
throw new \RuntimeException("Refusing to commit {$path}: too large for git storage.");
}
$this->inner->write($path, $contents, $config);
}
// ...delegate the rest
}
That decorator is also the right place to filter .gitkeep from listings, normalise paths, and emit timers — the cheap way to make a thin, single-maintainer adapter safe to depend on.
How can I help you explore Laravel packages today?