thephpleague

Stars and mentions

Last 30 days, Oct 4 – Nov 2

GitHub stars8
mentions3
01234Oct 4Oct 18Nov 2
UTC days · repositories thephpleague owns1 repositories

Package downloads

No package download history available yet.

Top repositories

thephpleague’s repositories ranked by new stars, as on trending.

Loading

Contributes to

Repositories outside thephpleague’s account where thephpleague opened or reviewed pull requests in the past 365 days.

Who noticed thephpleague

Everyone who starred or forked thephpleague’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

3posts
Nov 30 posts
  1. 🚀 Released league/oauth2-server-bundle v1.2.3 and v2.0.0, the #Symfony integration for league/oauth2-server library. Mostly leveraging #PHP, Symfony, and oauth2-server's latest syntax and features. Shoutout to @ajgarlag.bsky.social for his great maintenance work 🤝 github.com/thephpleague/oauth2-server-bundle/rele…

    3 likes · 3 reposts
  2. Storage::put() in Your Use Case Is a Leak: Put Files Behind a Port in Laravel - Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework - Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- Your invoice upload has been "working" for weeks. Then someone asks for the PDF of invoice 4812 and the bucket has nothing at that key. No exception in the logs, no failed job. The use case called Storage::disk('s3')->put(...), and per the Laravel file storage docs, when a write fails put returns false unless the disk has 'throw' => true in its config. Nobody checked the return value, because nobody thinks of a file write as something that answers with a boolean. That is one cost of calling the Storage facade from inside a use case. Your business rule now depends on a key in config/filesystems.php, and whether a failed write is loud or silent is decided by a config flag the use case never sees. The fix is five small pieces: a DocumentStore interface the domain owns, a Flysystem-backed adapter, an in-memory adapter for fast tests, a service provider binding, and a contract test both adapters pass. Everything targets PHP 8.3+ and Laravel 13, which requires PHP 8.3 as its minimum. What the facade call costs you Here is the shape that shows up in a lot of Laravel codebases: <?php namespace App\Billing; use Illuminate\Support\Facades\Storage; final class AttachInvoicePdf { public function __invoke(string $invoiceId, string $pdf): string { $path = "invoices/{$invoiceId}.pdf"; if (Storage::disk('s3')->exists($path)) { throw new \DomainException('Already attached'); } Storage::disk('s3')->put($path, $pdf); return $path; } } Three things leak out of those few lines. The disk name. 's3' is an infrastructure decision written into a billing rule. Giving invoices their own disk, or running them on local in staging, means editing the use case, even though nothing about billing changed. The failure mode. put reports failure as false by default. Laravel's FilesystemAdapter catches UnableToWriteFile, rethrows it only when the disk's throw option is set, and passes it to the exception handler only when the disk's report option is set; both default to false in the framework source. The code above ignores the return value, so the write can fail without anyone hearing about it. The test. To exercise this rule you need a booted Laravel application and Storage::fake('s3'). That fake is not an in-memory map: the Storage facade source builds a local driver rooted at storage/framework/testing/disks/{disk} and cleans that directory on each call. Your billing rule test now touches the real filesystem and the framework container to check one if. The port: four methods the domain actually needs Start from what the billing code needs, not from what Flysystem offers. It needs to store bytes under a key, read them back, ask whether something is there, and remove it. That is the whole port. <?php namespace App\Billing\Domain; interface DocumentStore { /** @throws DocumentStoreFailed */ public function put(DocumentKey $key, string $bytes): void; /** @throws DocumentNotFound|DocumentStoreFailed */ public function get(DocumentKey $key): string; /** @throws DocumentStoreFailed */ public function exists(DocumentKey $key): bool; /** Deleting a missing key is a no-op. */ public function delete(DocumentKey $key): void; } put returns void. Failure is an exception, always, regardless of which adapter sits behind it. That single decision removes the silent-false problem at the boundary. The key is a value object, so path rules live in one place: <?php namespace App\Billing\Domain; final readonly class DocumentKey { private function __construct(public string $value) {} public static function of(string $value): self { if ( $value === '' || str_starts_with($value, '/') || str_contains($value, '..') ) { throw new \InvalidArgumentException( "Invalid document key: {$value}" ); } return new self($value); } public static function forInvoice(string $invoiceId): self { return self::of("invoices/{$invoiceId}.pdf"); } } An invoice id of ../../etc/passwd never reaches an adapter. The two exceptions are plain domain types: <?php namespace App\Billing\Domain; final class DocumentNotFound extends \RuntimeException { public static function at(DocumentKey $key): self { return new self("No document at {$key->value}"); } } final class DocumentStoreFailed extends \RuntimeException {} In a real project, give each class its own file so Composer's PSR-4 autoloader finds it. They share a block here to save scrolling. With the port in place, the use case loses every trace of Laravel: <?php namespace App\Billing\Application; use App\Billing\Domain\DocumentKey; use App\Billing\Domain\DocumentStore; final readonly class AttachInvoicePdf { private const MAX_BYTES = 5 * 1024 * 1024; public function __construct(private DocumentStore $documents) {} public function __invoke( string $invoiceId, string $pdf, ): DocumentKey { if (!str_starts_with($pdf, '%PDF-')) { throw new InvoiceRejected('Not a PDF'); } if (strlen($pdf) > self::MAX_BYTES) { throw new InvoiceRejected('PDF over 5 MB'); } $key = DocumentKey::forInvoice($invoiceId); if ($this->documents->exists($key)) { throw new InvoiceRejected( "Invoice {$invoiceId} already has a PDF" ); } $this->documents->put($key, $pdf); return $key; } } InvoiceRejected is a one-line final class InvoiceRejected extends \DomainException {}. The rules (PDF magic bytes, size cap, one PDF per invoice) read top to bottom with no disk names and no facade. The controller turns the upload into a string and calls the use case; that is where $request->file('pdf') belongs. Two adapters: Flysystem for real, an array for tests The Laravel docs open by saying the filesystem layer is built on Flysystem, and Laravel 13's composer.json requires league/flysystem ^3.25.1. So you already have it installed. The production adapter wraps Flysystem's FilesystemOperator and translates its exceptions. Every Flysystem exception implements the League\Flysystem\FilesystemException marker interface, per the exception handling docs, so one catch covers write, read, existence-check and delete failures. <?php namespace App\Billing\Infrastructure; use App\Billing\Domain\DocumentKey; use App\Billing\Domain\DocumentNotFound; use App\Billing\Domain\DocumentStore; use App\Billing\Domain\DocumentStoreFailed; use League\Flysystem\FilesystemException; use League\Flysystem\FilesystemOperator; final readonly class FlysystemDocumentStore implements DocumentStore { public function __construct( private FilesystemOperator $fs, ) {} public function put(DocumentKey $key, string $bytes): void { try { $this->fs->write($key->value, $bytes); } catch (FilesystemException $e) { throw $this->failed('write', $key, $e); } } public function get(DocumentKey $key): string { if (!$this->exists($key)) { throw DocumentNotFound::at($key); } try { return $this->fs->read($key->value); } catch (FilesystemException $e) { throw $this->failed('read', $key, $e); } } public function exists(DocumentKey $key): bool { try { return $this->fs->fileExists($key->value); } catch (FilesystemException $e) { throw $this->failed('check', $key, $e); } } public function delete(DocumentKey $key): void { if (!$this->exists($key)) { return; } try { $this->fs->delete($key->value); } catch (FilesystemException $e) { throw $this->failed('delete', $key, $e); } } private function failed( string $op, DocumentKey $key, FilesystemException $e, ): DocumentStoreFailed { return new DocumentStoreFailed( "Document {$op} failed: {$key->value}", previous: $e, ); } } The exists guard in get and delete costs an extra request per call on a remote backend like S3. It buys a uniform contract: "not found" is a domain exception and deleting twice is safe, whatever the backend does by default. If the round trip matters at your volume, drop the guard and let the contract test tell you whether your backend already behaves that way. The test adapter is an array: <?php namespace App\Billing\Infrastructure; use App\Billing\Domain\DocumentKey; use App\Billing\Domain\DocumentNotFound; use App\Billing\Domain\DocumentStore; final class InMemoryDocumentStore implements DocumentStore { /** @var array<string, string> */ private array $files = []; public function put(DocumentKey $key, string $bytes): void { $this->files[$key->value] = $bytes; } public function get(DocumentKey $key): string { return $this->files[$key->value] ?? throw DocumentNotFound::at($key); } public function exists(DocumentKey $key): bool { return isset($this->files[$key->value]); } public function delete(DocumentKey $key): void { unset($this->files[$key->value]); } } You could instead pass Flysystem's own InMemoryFilesystemAdapter (package league/flysystem-memory) into FlysystemDocumentStore. That is a fine choice too. The hand-rolled array has no dependency and lets the use-case tests run without any Flysystem code at all. Now the use-case test needs neither the container nor a fake disk: <?php namespace Tests\Unit\Billing; use App\Billing\Application\AttachInvoicePdf; use App\Billing\Application\InvoiceRejected; use App\Billing\Infrastructure\InMemoryDocumentStore; use PHPUnit\Framework\TestCase; final class AttachInvoicePdfTest extends TestCase { public function test_stores_pdf_under_invoice_key(): void { $docs = new InMemoryDocumentStore(); $attach = new AttachInvoicePdf($docs); $key = $attach('inv-42', '%PDF-1.7 body'); self::assertSame('invoices/inv-42.pdf', $key->value); self::assertSame('%PDF-1.7 body', $docs->get($key)); } public function test_rejects_a_second_pdf(): void { $attach = new AttachInvoicePdf( new InMemoryDocumentStore(), ); $attach('inv-42', '%PDF-1.7 first'); $this->expectException(InvoiceRejected::class); $attach('inv-42', '%PDF-1.7 second'); } } It extends PHPUnit's TestCase, not Laravel's. No storage/ directory is touched. Wiring it in a service provider The disk choice moves to config, where it belongs. Create config/documents.php: <?php return [ 'disk' => env('DOCUMENTS_DISK', 'local'), ]; Then bind the port. Storage::disk() returns Laravel's FilesystemAdapter, and its getDriver() method returns the underlying League\Flysystem\FilesystemOperator (see the framework source). That raw operator throws on failure, whatever the disk's throw setting says, which is what the adapter's catch blocks expect. <?php namespace App\Providers; use App\Billing\Domain\DocumentStore; use App\Billing\Infrastructure\FlysystemDocumentStore; use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Support\Facades\Storage; use Illuminate\Support\ServiceProvider; final class DocumentStoreServiceProvider extends ServiceProvider { public function register(): void { $this->app->bind(DocumentStore::class, function () { /** @var FilesystemAdapter $disk */ $disk = Storage::disk(config('documents.disk')); return new FlysystemDocumentStore( $disk->getDriver(), ); }); } } Add the provider to bootstrap/providers.php. Going to S3 is now an environment change: install league/flysystem-aws-s3-v3 as the S3 driver section describes, fill in the AWS_* variables, and set DOCUMENTS_DISK=s3. The same s3 disk config also talks to S3-compatible services such as Cloudflare R2 or DigitalOcean Spaces through its endpoint option, per the same page. AttachInvoicePdf does not change. The binding uses bind, not singleton, so a feature test that calls Storage::fake('local') before resolving the use case still gets the fake disk. Your HTTP-level tests keep working the way they did. The contract test both adapters must pass The in-memory adapter is only a safe stand-in if it behaves like the real one. Write the expectations once, in an abstract test, and run them against each adapter: <?php namespace Tests\Contract; use App\Billing\Domain\DocumentKey; use App\Billing\Domain\DocumentNotFound; use App\Billing\Domain\DocumentStore; use PHPUnit\Framework\TestCase; abstract class DocumentStoreContract extends TestCase { abstract protected function store(): DocumentStore; public function test_put_then_get_round_trips_bytes(): void { $store = $this->store(); $key = DocumentKey::of('invoices/a.pdf'); $bytes = "%PDF-1.7\x00\xff\n"; $store->put($key, $bytes); self::assertTrue($store->exists($key)); self::assertSame($bytes, $store->get($key)); } public function test_put_overwrites(): void { $store = $this->store(); $key = DocumentKey::of('invoices/b.pdf'); $store->put($key, 'old'); $store->put($key, 'new'); self::assertSame('new', $store->get($key)); } public function test_missing_key_is_not_found(): void { $store = $this->store(); $key = DocumentKey::of('invoices/none.pdf'); self::assertFalse($store->exists($key)); $this->expectException(DocumentNotFound::class); $store->get($key); } public function test_delete_is_idempotent(): void { $store = $this->store(); $key = DocumentKey::of('invoices/c.pdf'); $store->put($key, 'x'); $store->delete($key); $store->delete($key); self::assertFalse($store->exists($key)); } } The file is named DocumentStoreContract.php, so PHPUnit's default *Test.php discovery skips the abstract class and only runs the concrete ones. A stock Laravel phpunit.xml only scans tests/Unit and tests/Feature, so add a <testsuite name="Contract"> with <directory>tests/Contract</directory> to it, or these tests never run. The in-memory subclass is short: <?php namespace Tests\Contract; use App\Billing\Domain\DocumentStore; use App\Billing\Infrastructure\InMemoryDocumentStore; final class InMemoryDocumentStoreTest extends DocumentStoreContract { protected function store(): DocumentStore { return new InMemoryDocumentStore(); } } The Flysystem one runs against a real directory using the local adapter that ships in the core league/flysystem package: <?php namespace Tests\Contract; use App\Billing\Domain\DocumentStore; use App\Billing\Infrastructure\FlysystemDocumentStore; use Illuminate\Filesystem\Filesystem as Files; use League\Flysystem\Filesystem; use League\Flysystem\Local\LocalFilesystemAdapter; final class FlysystemDocumentStoreTest extends DocumentStoreContract { private string $root; protected function setUp(): void { $this->root = sys_get_temp_dir() . '/docs-' . bin2hex(random_bytes(4)); } protected function tearDown(): void { (new Files())->deleteDirectory($this->root); } protected function store(): DocumentStore { return new FlysystemDocumentStore(new Filesystem( new LocalFilesystemAdapter($this->root), )); } } When you move to S3, add a third subclass whose store() builds a Filesystem over the S3 adapter pointed at a throwaway bucket, and run it in CI against a local S3-compatible server. If S3 reports "not found" or overwrites differently from your local disk, this is where you find out, before production does. What this port leaves out, on purpose The port takes the whole file as a string. For a 5 MB invoice cap that is fine. For video or large exports it is not: add a putStream(DocumentKey $key, $resource) method backed by Flysystem's writeStream, which the Filesystem API docs document alongside write, and add a contract case for it. It also has no URLs. Temporary download links are a delivery concern, so they belong in a separate port (say, DocumentLinks) implemented with Storage::temporaryUrl(), not in the store the use case writes to. Keep the port as small as the rules that call it. When a new rule needs a new capability, add the method, add the contract case, and both adapters have to earn it. --- If this was useful The DocumentStore port is one small instance of the idea Decoupled PHP is built around: the domain names what it needs, Laravel or Symfony gets wired in at the edge, and swapping a vendor turns into a new adapter plus a passing contract test. The book applies the same move across a whole application, keeping the framework at the edge, with runnable examples. ![Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework](amazon.com) Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

    1 reaction
  3. OIDC Provider (OP) library creation Hi all! So recently, I was trying to find a package that would provide an OIDC Server implementation that I could integrate into my project to provide feature-rich functionality for OIDC authentication. Unfortunately, I was not able to find any package to cover my needs: thephpleague/oauth2-server - provides only an OAuth2 server, no OIDC oidcphp/core - seems to be abandoned PEOFIAMP/phpoidc - listed as a certified library but does not look like a usable library Because of that, I've started thinking about actually building one that would also be universal in terms of implementation and dependencies, so anyone will be able to pull it into their own project by extending/implementing the needed contracts. My initial plan is to take some package from another language and at least implement some portion of the specifications for the OIDC provider protocol. After that, I think it would also be a great idea to provide packages for any popular frameworks (Symfony, Laravel, etc.). Because of that, I would like to ask if any of you see possible use cases for your own projects? I mean, if you see that it would be really worth trying to build and share with the public. Thank you in advance for any additional ideas/topics/suggestions that might be helpful to consider. And I would be happy to get any feedback to understand if it even makes sense to move forward with this whole idea.

    7 points · 4 comments
Trending awards, every completed day and week
Gold
01st place finishes
Silver
02nd place finishes
Bronze
03rd place finishes
Awarded repositories
0Owned by thephpleague now

No trending awards yet

None of the repositories thephpleague owns has finished in the top three of a completed trending day or week, by stars, forks or PR authors. See who is trending now.

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.