AI 资讯
How a Five Line Architecture Test Caught a Data Leak a Code Review Missed
TL;DR: Pest PHP can test the structure of your code, not just its behavior. Write your team rules as architecture tests and CI enforces them on every commit. One such test caught a multi-tenant data leak that a human review had missed. We had a rule. Every model holding tenant-specific data must use our BelongsToTenant trait. That trait adds the global scope that keeps one clinic from seeing another clinic's data. The rule was in onboarding. It was in the code review checklist. Everyone knew it. A developer joined the team. Three weeks in they added a new model and forgot the trait. The reviewer was focused on the business logic, which was genuinely well written, and did not notice the missing trait. The model shipped. For two days one clinic could see fragments of another clinic's data in one specific report. A support ticket caught it. Our tests did not. That was the day architecture tests went into the project. What an Architecture Test Is Most tests check behavior. Given this input the function returns that output. An architecture test checks structure instead. It asserts things about how the code is organized rather than what it computes. Pest has an arch function for exactly this. // tests/Architecture/ArchTest.php arch ( 'tenant models must use the BelongsToTenant trait' ) -> expect ( 'App\Models' ) -> toUseTrait ( 'App\Traits\BelongsToTenant' ) -> ignoring ( 'App\Models\SystemSetting' ); arch ( 'controllers may not touch the DB facade directly' ) -> expect ( 'App\Http\Controllers' ) -> not -> toUse ( 'Illuminate\Support\Facades\DB' ); arch ( 'services may not depend on the HTTP request' ) -> expect ( 'App\Services' ) -> not -> toUse ( 'Illuminate\Http\Request' ); arch ( 'no env calls outside config files' ) -> expect ( 'App' ) -> not -> toUse ( 'env' ); These run in CI on every commit. Break a rule and the build fails with a message naming the rule and the file that broke it. The Tests That Earned Their Keep The tenant trait test caught four more models over
AI 资讯
Composer Update Is Not Safe Anymore
Saturday morning. I opened Twitter and saw a tweet about the Laravel-Lang packages being compromised. My first reaction was simple: "I don't use that package." Then I opened composer.json on a project I work on and found this in require-dev : "laravel-lang/lang" : "^14.8" , "laravel-lang/publisher" : "^16.8" , That changed things. What Actually Happened The attack used laravel-lang packages as the distribution channel. And the sneaky part: the main repository branch looked completely clean. No suspicious commits, no new code. The malicious payload was pushed via git tags on forks . Most developers would not notice anything. Just a routine composer update , same as always. Once installed, the payload executed at autoload time . That means every php artisan command, queue worker, or web request running that codebase triggered the malware the moment PHP hit require_once __DIR__.'/../vendor/autoload.php' in public/index.php . Silently. No error, no red screen, nothing. The malware was a credential stealer. It searched the machine for: .env files from Laravel projects AWS access keys and session tokens SSH private keys GitHub CLI tokens NPM tokens Infrastructure secrets This is not a SQL injection that messes with your database rows. This is a key stealer that runs on your machine and takes everything it finds. Aikido Security caught it and reported it to Packagist. Packagist removed the affected versions. But if you ran composer update during that window, you were exposed. Why Supply Chain Attacks Are Different Classic Laravel security talks about SQL injection, XSS, CSRF. Those are attacks that come from outside users sending malicious input to your application. Supply chain attacks come from inside your own development process. The attacker does not need to find a vulnerability in your code. They need to compromise one developer account at one package maintainer. Every project depending on that package is now exposed. With AI tools, these attacks are getting more soph
AI 资讯
One Nullable Timestamp, Four Account States: Deriving User Status in Laravel
Most of today went into a user-management overhaul in kickoff — my Laravel starter kit. Flyout CRUD panels, bulk actions, permission assignment, and the piece I want to talk about: account status . Active, suspended, unverified, deleted. The interesting part isn't the feature. It's the modelling decision underneath it. Where do those four states actually live ? The trap: a status column The obvious move is a status enum column on users . Set it to suspended when you suspend someone, active when you reinstate, unverified until they verify their email, deleted when they're soft-deleted. It works. Until it doesn't. Now you've got a status column and an email_verified_at column and a deleted_at from soft deletes — and all three encode overlapping truths. Soft-delete a user and forget to flip status ? Now your database says the account is both active and trashed. Verify an email but the status update fails mid-request? Drift. Every place that mutates a user becomes a place that has to remember to keep status in sync. That's not a feature, that's a maintenance tax. The signals already exist. email_verified_at tells you verified-or-not. deleted_at (soft deletes) tells you removed-or-not. The only genuinely new state is suspended — an admin deliberately blocking sign-in. So that's the only thing worth storing. What we actually store: one nullable timestamp Schema :: table ( 'users' , function ( Blueprint $table ) { $table -> timestamp ( 'suspended_at' ) -> nullable () -> after ( 'email_verified_at' ); }); That's the whole migration. Not a boolean is_suspended — a nullable timestamp. Null means not suspended; a value means suspended and tells you when. A boolean throws that second fact away for free; the timestamp keeps it at no extra cost. Same instinct as email_verified_at and deleted_at — Laravel models "did this happen, and when" as a nullable timestamp everywhere, so we follow the grain of the framework. The behaviour on the model stays tiny: public function isSuspended
AI 资讯
Why an encrypted config backup breaks when you move servers — and how I fixed it in laravel-config-backup
Imagine you write a letter in a secret code that only your old house key can read. Then you move. You photocopy the coded letter, carry it to the new house… and realise the new key can't decode any of it. The letter is valid, just useless. That's effectively what happens when you back up encrypted values from a Laravel database and restore them onto a different server. I hit exactly this while working on laravel-config-backup today, so here's the problem and the fix. The real cause: Crypt is bound to APP_KEY When you store sensitive settings (think API tokens or OAuth secrets) in the database, you typically encrypt them with Crypt::encryptString() . Lovely — until you remember Crypt uses your app's APP_KEY as the key. A naive backup copies that ciphertext straight across: // Naive approach — move the ciphertext as-is $value = DB :: table ( 'settings' ) -> where ( 'key' , 'some.secret' ) -> value ( 'value' ); // this value is encrypted with the OLD server's APP_KEY The new server has a different APP_KEY . Try to decrypt → DecryptException: The payload is invalid . Your backup is technically complete but practically dead. The fix: decrypt on the way out, re-encrypt on the way in The decision is easy to state, hard to stay disciplined about: never carry ciphertext across a server boundary. Instead — On create : decrypt the values with the source server's APP_KEY , store plaintext inside the archive. Protect that archive with AES-256 and a password (a human-held secret, not the APP_KEY). On restore : re-encrypt the values with the destination server's APP_KEY before writing to the DB. Back to the analogy: you decode the letter, carry the plain letter in a locked briefcase (the password-protected archive), and re-encode it with the new house's lock on arrival. The briefcase handles security in transit — not the old code that's no longer relevant. I made that intent explicit right where the behaviour lives, in ConfigBackupService : /** * Config Backup & Restore. * * Bundl
AI 资讯
Shipping a Livewire 4 + Flux admin UI inside a package: four gotchas that 500'd on me
Bundling an admin UI inside a Laravel package is a different game from building one in an app. The app's conveniences — a compiled Vite manifest, a registered layout, your own Livewire components — aren't there. Today, getting the bundled admin UI in laravel-config-webhook to actually render meant walking through four separate 500s. Each one is a small, sharp lesson about the boundary between a package and its host app. 1. A Livewire 4 component name can't contain :: I registered the component with a namespaced-looking name and got a ComponentNotFoundException at runtime. The cause is subtle: under Livewire 4, a name containing :: triggers namespace resolution that ignores singly-registered components. So a "nice looking" name silently routes to a lookup that will never find it. The fix is to register a plain, dotted name: // ❌ looks tidy, but the "::" sends Livewire down a namespace path Livewire :: component ( 'config-webhook::webhooks' , Webhooks :: class ); // ✅ a flat dotted name resolves to the singly-registered component Livewire :: component ( 'config-webhook.webhooks' , Webhooks :: class ); Lesson: in a package, treat the component name as an identifier with framework-reserved characters — :: is not yours to use. 2. Flux ships Heroicons, not Pro/Lucide names The free tier of Flux ships Heroicons . Reach for a Pro-only or Lucide-style name and it throws at runtime. I'd used webhook , ellipsis , and list ; the free equivalents are bolt , ellipsis-horizontal , and queue-list . This is the same trap that bit my SSO package — which is exactly why I now guard it with a static test that reads the Blade and checks every icon against Flux's actual stub files. (Separate post on that.) If you ship a package UI with Flux, assume free-tier icons only unless you require Pro. 3. Don't @vite host assets that don't exist The bundled fallback layout @vite -d the host app's assets. In a fresh consumer (or the package's own workbench) there's no compiled manifest, so you get a
AI 资讯
A test that catches the bug your feature tests can't see
There's a class of bug that's maddening: it passes every test you have, then crashes in the user's face. I hit one in the admin UI of laravel-config-sso today, and the real fix wasn't changing an icon name — it was writing a test that could see the bug in the first place. The bug: wrong icon name, crashes only at runtime The admin UI uses Flux . Flux resolves icons through <flux:delegate-component> , and it throws for a name that doesn't exist: Flux component [icon.ellipsis] does not exist. It's an easy mistake. Flux ships Heroicons , not Lucide. So your Lucide reflexes lie to you: You type (Lucide) Flux wants (Heroicon) ellipsis ellipsis-horizontal trash-2 trash eye-off eye-slash Why feature tests don't catch it Here's the interesting part. I had a feature test that hits the admin route and asserts 200. Green. But the real UI crashes. How? Because in the headless test harness, Flux renders icons as no-ops. No real <flux:delegate-component> boots, so the icon name never gets resolved. The crash only surfaces under a full boot ( testbench serve ) — exactly where your automated tests don't go. Analogy: it's like a spell-checker that only runs when you print the document, not while you type. Your tests type away happily. The crash waits at the printer. The fix: a static test that reads the Blade and validates every icon Instead of relying on runtime, I wrote a Pest test that reads the Blade view, extracts every icon name (static and inside dynamic expressions), and asserts Flux actually ships a stub for each one: $fluxIconStubs = base_path ( 'vendor/livewire/flux/stubs/resources/views/flux/icon' ); it ( 'only references Flux icons that exist' , function () use ( $fluxIconStubs ) { expect ( is_dir ( $fluxIconStubs )) -> toBeTrue ( "Flux icon stubs not found" ); $view = file_get_contents ( __DIR__ . '/../../resources/views/livewire/sso-providers.blade.php' ); // Static `icon="name"` plus quoted tokens inside dynamic // `icon="{{ $cond ? 'eye-slash' : 'eye' }}"` expressio
AI 资讯
Making encrypted Laravel config backups portable across APP_KEYs
Here's a fun one. You build a package that backs up an app's config — the .env plus the settings stored encrypted in the database — into a single password-protected ZIP. The whole selling point is portability : take a backup on server A, restore it on server B, even when the two servers have different APP_KEY s. Then you write a test that actually changes the key during a restore, and it fails. The DB settings come back garbled. Turns out the bug wasn't in the encryption at all. It was in a cache I forgot was there. Today I shipped 1.1.0 of laravel-config-backup and this portability fix was the headline. Let me walk through it, because the lesson generalizes way beyond this package. Why APP_KEY portability is even a thing Laravel encrypts things with APP_KEY . Encrypted Eloquent casts, signed cookies, sessions — all of it keys off that value. So if you naively mysqldump a table with encrypted columns and load it onto another server, every encrypted column is now ciphertext that the new key can't decrypt. Dead data. The trick this package uses is to store the archive contents decrypted . When I export the database, rows go out through their casts , so an encrypted column becomes a plain value inside the ZIP (the ZIP itself is AES-256 password-encrypted, so it's not sitting around in plaintext). On import, each row is written back through the model , which means the cast re-encrypts it with whatever APP_KEY is active on the destination. Server A (key A) Archive (decrypted) Server B (key B) ──────────────── ─────────────────── ──────────────── settings.payload ──decrypt──▶ "Portable" ──import──▶ settings.payload (ciphertext A) (cast) (cast) (ciphertext B) Think of it like shipping furniture: you don't ship the assembled wardrobe through a doorway it doesn't fit, you flat-pack it and reassemble at the destination with the screws you have there. The restore sequence A restore that also brings a new .env has to be careful about ordering . Here's the real flow: public func
AI 资讯
Build a versioned Laravel API with auto-generated OpenAPI docs in 10 minutes
TL;DR — We'll install dskripchenko/laravel-api , write one controller, and end up with a versioned API ( /api/v1/... ) and interactive OpenAPI 3.0 docs at /api/doc — generated from the docblock you'd write anyway. Then we'll ship a v2 without copy-pasting a single controller. The problem Two things rot in every growing Laravel API: Versioning. v1 ships, then v2 needs to change three endpoints but keep the other twenty. You either copy-paste a V2 folder (and now bugfixes live in two places) or bolt if ($version === 2) branches into your controllers. Docs. The OpenAPI spec drifts from the code the moment you merge. Annotation libraries ( #[OA\Get(...)] , giant YAML files) ask you to describe your API twice — once in code, once in attributes. This package's bet: your controller already describes itself . The method name, the request fields, the response shape — write them once, as a normal PHPDoc, and let the package derive routes and docs from it. Versioning becomes plain PHP inheritance. Let's build it. What we'll build A tiny tasks API: POST /api/v1/task/list — list tasks POST /api/v1/task/create — create one interactive docs at GET /api/doc (raw spec per version at /api/doc/{version} ) then a v2 that adds an endpoint without touching v1 Total: ~4 small files. Step 0 — Install composer require dskripchenko/laravel-api Publish the config (optional, but handy to see the knobs): php artisan vendor:publish --tag = laravel-api-config // config/laravel-api.php return [ 'prefix' => 'api' , // → /api/... 'uri_pattern' => '{version}/{controller}/{action}' , 'available_methods' => [ 'get' , 'post' , 'put' , 'patch' , 'delete' ], 'openapi_path' => 'public/openapi' , 'doc_middleware' => [], // lock down /api/doc here ]; Step 1 — Write a controller Nothing exotic — it extends the package's ApiController , which gives you response helpers ( success() , error() , validationError() , created() , noContent() , notFound() ). The docblock is the documentation : <?php namespace App\Api
AI 资讯
I shipped a support desk by deleting a dependency
I added a support desk to LaraFoundry this week. The first commit in the slice removed a package instead of adding one. LaraFoundry is a reusable SaaS core for Laravel that I'm extracting in public from an older app of mine. Auth, multi-tenancy, roles, activity log, notifications, billing seam, and now support tickets. The rule for every module is the same: lift the proven idea out of the old code, modernise it, harden it, and make it something you can composer require into a fresh Laravel app without inheriting a pile of assumptions. Tickets is where that rule got interesting, because the old code didn't own its ticket model. It leaned on a third-party ticket library. Why a ticket package is wrong for a reusable core A third-party ticket package is a perfectly reasonable choice when you're building one app. You get tables, a model, a status enum and a UI scaffold for free. It's the wrong choice for a core that other apps install. Pull it into the core and every host app inherits that package's migrations, its table names, its status vocabulary and its idea of what a ticket is. The dependency becomes load-bearing in projects that never asked for it, and the day it lags a Laravel release, every downstream app waits. So I cut it (decision D-4.2-1 in my notes) and wrote the model by hand. The model is about 180 lines. There is no magic. Two tables, a uuid, a status, a couple of scopes. The diff against "depend on the package" was less code in the core, not more, because I only kept the behaviour I actually use. Here's the top of the model, with the extraction notes I leave for future me: /** * A support ticket: the channel between a host user and the platform operator. * * Extracted from the donor App\Models\Ticket, which sat on a third-party * ticket package. That dependency is cut: this is a self-contained model. * Categories and labels are JSON slug arrays driven by config, not pivot * tables. The dead assigned_to column and the donor's invalid-operator * query are
AI 资讯
Typed Eloquent boundaries without building a second ORM
Most Laravel teams do not need to "fix" Eloquent. They need to stop letting raw model state leak too far into code that makes real business decisions. That is the practical version of this debate. Typed objects around Eloquent can be a big improvement, but only when they are used as boundaries . If you push the pattern too far, you end up with a second object model shadowing the first one. At that point you are not improving Laravel. You are building a parallel ORM that adds mapping code, cognitive load, and friction on every change. So the right question is not, "Should we replace Eloquent with typed objects?" The right question is, where does untyped Eloquent stop being cheap? Once you frame it that way, the migration path becomes much clearer. Keep Eloquent where it is good at persistence, hydration, scopes, relationships, and query composition. Introduce typed objects where the shape is messy, the values carry business meaning, or invalid combinations are too easy to represent. That is the version that pays off. The Core Recommendation If you only remember one thing from this article, make it this: add typed boundaries around unstable or meaningful data, not around every model . That usually means one of four cases: a JSON column that multiple parts of the app interpret differently domain values like money, status, addresses, or billing configuration data crossing from Eloquent into services, jobs, or integrations code paths where stringly typed state has already caused confusion or bugs Everything else should be guilty until proven useful. This is where a lot of teams go wrong. They see a good example of typed objects and immediately generalize it into an architecture rule. Then every model gets a FooData , FooView , FooState , FooRecord , and FooMapper . The app becomes more "designed" and less understandable. A Laravel codebase does not get better because it has more classes. It gets better because responsibility becomes clearer . Why Raw Eloquent Starts Hurt
AI 资讯
Building a SaaS engine in public: shipping the billing seam, not billing
I tagged v0.9.0 of LaraFoundry this week: billing. Except the honest headline is that I shipped the billing seam , not billing. The free core now has the whole shape of a subscription system, a payment-gateway contract, a driver manager, a real access gate over subscription columns, and it cannot take a single cent. That is on purpose, and the reason is the most interesting part of the phase. LaraFoundry is a SaaS core I'm extracting in public from a live CRM, one module at a time. The deal I made with myself early on: the core is free and stays free for everything except money. Auth, multi-tenancy, RBAC, the admin console, the activity log, i18n, files: all free. The day a business wants to charge its customers , that is the paid part. So billing could not just be "another module." It had to split cleanly down a line, with the free side carrying real, useful structure and the paid side carrying the parts that actually move money. The donor habit I would not carry Here is what the original CRM did when a company "paid" for its subscription: // TODO: real payment gateway integration // TEMPORARY: every payment is successful, for testing $paymentStatus = 'success' ; That success was hardcoded. There was no Stripe, no Paddle, no gateway at all. "Paying" wrote a row into a company_payments table and flipped the subscription date forward. For a CRM I run myself, with one real user, that was fine: I never needed the real thing, so the placeholder sat there indefinitely. The moment this becomes a reusable core, that placeholder is poison. A success that is always true is worse than no gateway, because it looks like billing works. So the rule for this phase was simple: the fake gateway does not get extracted. Whatever stands in its place has to be honest about the fact that it takes no money. What the seam actually is The free core ships a PaymentGatewayInterface : subscribe, cancel, refund, status, and verify a webhook. It describes only the mechanics of moving money. It d
AI 资讯
My Days at Laravel Live Japan 2026
Japanese version available on note . Hi, I'm chatii @chatii . I recently attended Laravel Live Japan 2026. Here's what inspired me and what I took home from the conference. Profile Organizer of PHP Conference Kagawa Encountered PHP back in the 4.x era Freelancer English level: "Can read reasonably well," "Can write a little," "Can listen a bit," "Cannot speak at all." 5/23 PHP×Tokyo - Laravel Live Japan PRE-PARTY PHP×Tokyo - Laravel Live Japan PRE-PARTY - connpass (English follows Japanese) PHP×Tokyoは、PHPやLaravelが好きなエンジニアのためのインターナショナルなミートアップです。 日英のライブ翻訳付きなので、英語が得意でなくても大丈夫です!言語の壁を越えて、PHP/Laravelについて語り合いましょう! 登壇者も募集中です!登壇を希望する方はこちらからご応募ください。 登壇は日本語・英語どちらでも大丈夫です。 #### タイムテーブル * 13:00 - 13:30 受付 & ネットワーキング * 13:30 - 13:40 オープニング * 13:40 - 14:10 "Man... phpxtky.connpass.com I first participated in "PHP×Tokyo March 2026." It was my first time attending a meetup with international participants. I couldn't speak English, but I hoped to be able to communicate somehow. Back in March, David helped me immensely with translation, which made me feel a bit apologetic... At the PRE-PARTY, I took the plunge. During the networking session, I managed to approach Victor Ukam , who gave the talk "Manage AI Prompts as Versioned Files in PHP," and said in English, "I have a question...!" Well... communication after that relied on Google Translate, but I was able to overcome the "first hurdle." You could say I successfully executed <?= "Hello, World" ?> . Also, it was great to see Ivan again, who came from Russia. I first met him in March, and I was so happy he came over to say hello! 5/25 Eve of the Conference, Gyoza Restaurant Zumi organized an unofficial pre-party via the laravel-live-jp channel on the "Laravel Japan" Discord. Participating in these "fringe events" around a conference is always fun. I had booked a hotel from the day before, so I joined in. The real-time translation app that Albert Chen built was incredibly high-performance... 5/26 Day 1 ...Actually, I couldn't sleep at
AI 资讯
How I Fixed a PHP Version Mismatch on Hostinger Shared Hosting (And What Actually Made It Work)
I spent way too long staring at this error. If you're here, you probably are too. Your requirements could not be resolved to an installable set of packages. Problem 1 - Root composer.json requires php ^8.3 but your php version (8.2.30) does not satisfy that requirement. My Laravel 13 app needed PHP 8.3. My Hostinger server was running 8.2. composer install refused to budge. Here's exactly what happened and the one-liner that fixed it. The Setup I was deploying a Laravel 13 + Inertia + React app to Hostinger shared hosting. Laravel 13 requires PHP 8.3 minimum — and so do its locked Symfony 8.x and PHPUnit 12.x dependencies. My composer.lock had been generated on a local machine with PHP 8.3, but Hostinger's CLI was defaulting to 8.2. The hPanel showed PHP 8.3 selected under PHP Configuration . The website itself was running fine on 8.3. But SSH? Still on 8.2. $ php -v PHP 8.2.30 ( cli ) That disconnect — hPanel vs. CLI — is the trap. What I Tried First composer update My first instinct was to just let Composer resolve newer compatible versions: composer update No luck. The root composer.json itself declared "php": "^8.3" , so Composer refused before even touching the lock file. The PHP constraint wasn't just in dependencies — it was in my own project requirements. composer install --ignore-platform-reqs This flag skips platform checks and forces the install anyway. It works , but it's a lie — you end up with packages that may behave incorrectly or fail at runtime because they genuinely require PHP 8.3 features. Not a real fix. Changing PHP in hPanel Hostinger's control panel has a PHP version switcher under Hosting → Manage → PHP Configuration . I had already set this to 8.3. This controls the web server / FPM version — what runs your .php files in the browser. It does not change what php points to in your SSH terminal. That's the key distinction most tutorials miss. What Actually Fixed It Hostinger installs multiple PHP versions in parallel. They live in /opt/alt/ph
AI 资讯
Adding a full docker setup to the Filament Mastery Starters
For a while, my starter kits didn't include any Docker configuration. The foundation was solid with auth, roles, MFA, Horizon, Logs Viewer, but the deployment side was left to whoever cloned the project. That was a deliberate choice at first. Docker setups vary a lot depending on the infrastructure: some people use a reverse proxy, others have Cloudflare in front, some run on bare metal, others on managed platforms. I didn't want to ship something that would need to be ripped out immediately. But over time I changed my mind. Here's why and what the process taught me. The problem with "just configure it yourself" Leaving deployment out of a starter kit sounds reasonable. In practice, it means every project starts with the same 4-6 hours of Docker work that never really changes. Multi-stage Dockerfile. PHP-FPM config. Nginx with HTTPS. PostgreSQL and Redis wired up. Horizon and the scheduler running as proper services. Healthchecks everywhere so Docker knows when things are actually ready. None of it is so complicated. But it's time-consuming, easy to get subtly wrong, and almost identical from one project to the next. Once I admitted that, the question wasn't whether to include Docker, it was how to do it in a way that's actually useful without being too opinionated about production infrastructure. What I ended up building The setup I settled on covers the full local development stack: A multi-stage Dockerfile : separate stages for Composer dependencies, Node assets, and the final PHP-FPM image. Keeps the production image lean. Nginx with HTTP-to-HTTPS redirect and a self-signed certificate for local dev, already included, no setup needed. PostgreSQL and Redis as services with proper healthchecks. Horizon and the scheduler as dedicated services, not crammed into the main app container. A bootstrap service that runs php artisan migrate --force before the app starts. The Dockerfile uses three stages to keep the final image as lean as possible: FROM php:8.4-fpm-alpine A