diff --git a/PRINCIPLES/laravel-composer-packages.md b/PRINCIPLES/laravel-composer-packages.md new file mode 100644 index 0000000..aae2c06 --- /dev/null +++ b/PRINCIPLES/laravel-composer-packages.md @@ -0,0 +1,699 @@ +# Blax Software — Laravel Composer Package Principles + +This document is the single source of truth for how every Blax Software +Laravel composer package (open-source or internal) is built. It exists so +that any consumer who installs one of our packages can rely on a +predictable shape, and so that any maintainer who jumps between packages +finds the same patterns. + +If you are creating a new package, copy these conventions verbatim. If a +package deviates, the deviation must be justified inline in that package +(`README.md` or service provider docblock) and ideally lifted back into +this document. + +--- + +## 1. Migrations: hybrid auto-load + publishable + +Every package ships migrations as **real timestamped `.php` files** living +in `database/migrations/`. They are NOT `.stub` files. The service provider +both auto-loads them AND offers them for publishing. + +This gives consumers the best of both worlds: + +- **Plug-and-play**: `composer require …` + `php artisan migrate` works on + a fresh install. No `vendor:publish` step needed for the schema baseline. +- **Future updates**: when the package ships new additive migrations + (added columns, new tables, indexes, fixups), the consumer just runs + `composer update && php artisan migrate` — the new migration auto-loads + from `vendor/` and the migrator picks it up. +- **Escape hatch**: consumers who want to customise the schema (different + ID types, multi-tenant prefixes, extra columns) can publish the + migrations and disable auto-load. + +### Pattern (canonical — laravel-roles / laravel-shop) + +**File layout** + +``` +database/migrations/ + 2025_01_01_000001_create_blax__tables.php + 2025_01_01_000002_.php + 2026_04_26_000001_.php +``` + +Use the package's first-release date as the timestamp prefix for the +baseline (`2025_01_01_000001_…`) so it sorts before anything a consumer +already has. Each subsequent migration gets its own real timestamp. + +**Service provider** (`ServiceProvider.php`) + +```php +public function boot(): void +{ + $this->offerPublishing(); + $this->registerMigrations(); + // … +} + +/** + * Auto-load the package's migrations so fresh installs work without + * publishing. Disabled via `.run_migrations = false` for + * projects that prefer to publish + manage migrations themselves. + */ +protected function registerMigrations(): void +{ + if (! config('.run_migrations', true)) { + return; + } + + $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); +} + +/** + * Publishing preserves the SOURCE filename so that any migration + * already run via auto-load is marked as run for the published copy + * too — no duplicate execution. + */ +protected function offerPublishing(): void +{ + if (! $this->app->runningInConsole()) { + return; + } + + $this->publishes([ + __DIR__ . '/../config/.php' => $this->app->configPath('.php'), + ], '-config'); + + $migrationsPath = __DIR__ . '/../database/migrations'; + $publishMap = []; + foreach (glob($migrationsPath . '/*.php') as $sourcePath) { + $publishMap[$sourcePath] = $this->app->databasePath('migrations/' . basename($sourcePath)); + } + $this->publishes($publishMap, '-migrations'); +} +``` + +**Config key** + +```php +// config/.php +return [ + /* + * Whether the package should auto-run its migrations. See + * laravel-workkit/PRINCIPLES/laravel-composer-packages.md. + */ + 'run_migrations' => true, + // … +]; +``` + +### Why the filename-preserving publish is critical + +Laravel's `migrations` table records the migration *filename*. If the +published copy has a different filename than the source (e.g. a fresh +`date('Y_m_d_His')` timestamp), Laravel sees it as a brand-new migration +and runs it again, on top of the auto-loaded copy. By copying with +`basename($sourcePath)` we keep the filenames identical, so the migrator +deduplicates correctly. + +### Idempotency requirement + +Every migration MUST be safe to run when its tables/columns already +exist. Guard each `Schema::create` with `if (! Schema::hasTable(...))` +and each `Schema::table` column addition with +`if (! Schema::hasColumn(...))`. Reason: in real consumer projects +people *will* end up with both a published copy (with a different +timestamp) and the auto-loaded copy, and we want graceful degradation +instead of fatal errors. + +### Deviation: laravel-addresses + +`laravel-addresses` keeps the original `create_blax_address_tables.php.stub` +as a publish-only stub because some downstream apps already published a +heavily-customised version (UUID PKs, extra columns) and we cannot safely +re-run the baseline against them. *Additive* migrations there still +follow this principle — plain `.php` files, auto-loaded. New packages +should default to the full hybrid (laravel-roles style); only use the +laravel-addresses split if you have an existing customisation problem to +work around. + +--- + +## 2. README structure (open-source packages) + +Every Blax Software OSS package README has the **same four mandatory +anchors** and the same final closer. Between them the package author is +free to grow the README to whatever depth the feature surface needs. + +### The skeleton + +| # | Section | Status | +|---|---|---| +| 1 | OSS banner (linked from laravel-workkit) | **Mandatory** | +| 2 | Title + badges below | **Mandatory** | +| 3 | Emoji feature list of what the package provides | **Mandatory** | +| 4 | Quickstart (install + minimum viable usage) | Suggested | +| 5 | Quick configuration overview of features | Suggested | +| 6 | Anything else (advanced usage, testing, security, credits, license, changelog, etc.) | Free-form | +| 7 | Star History | **Mandatory** | + +The order matters — consumers skim top-to-bottom. The four mandatory +items (1, 2, 3, 7) bookend every README and make every Blax repo feel +familiar within two seconds. Between section 3 and 7 the author has +total freedom: a tiny package may go banner → title+badges → features → +quickstart → star history and stop; a large one (see laravel-mail) can +have 15 sections of advanced material in between. + +### The canonical skeleton, fleshed out + +```markdown + +[![Blax Software OSS](https://raw.githubusercontent.com/blax-software/laravel-workkit/master/art/oss-initiative-banner.svg)](https://github.com/blax-software) + + +# + +<!-- Pick badges that are common in this repo's stack — see "Badges" below --> +[![PHP Version](https://img.shields.io/badge/php-%5E8.2-blue)](https://php.net) +[![Laravel](https://img.shields.io/badge/laravel-10.x--13.x-orange)](https://laravel.com) + +<One-sentence description of what the package does — the elevator pitch.> + +<!-- 3. Emoji feature list — mandatory --> +## Features + +- 🛍️ **Headline feature** — short benefit-oriented line +- 💰 **Next feature** — what it gives the consumer +- 📦 **…** — keep each item one line; emoji + bold short title + benefit +- 🎯 … + +<!-- 4. Quickstart — suggested --> +## Quick Start + +```bash +composer require blax-software/<repo> +php artisan migrate +``` + +<The shortest possible "hello world" — get a consumer to a working call +in under 30 seconds. Use real model names from the package.> + +<!-- 5. Quick configuration overview — suggested --> +## Configuration + +<Brief tour of the most useful config knobs (table-name overrides, model +bindings, the run_migrations flag, env vars). Don't repeat the whole +config file — link to `config/<package>.php` in the repo for the rest.> + +<!-- 6. Anything else — free-form. Examples below; pick what's relevant. --> +## Advanced Usage / Requirements / Testing / Documentation / Security / Credits / License / Changelog … + +<!-- 7. Star History — mandatory, always last --> +## Star History + +<a href="https://www.star-history.com/?repos=blax-software%2F<repo>&type=date&legend=top-left"> + <picture> + <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=blax-software/<repo>&type=date&theme=dark&legend=top-left" /> + <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=blax-software/<repo>&type=date&legend=top-left" /> + <img alt="Star History Chart" src="https://api.star-history.com/chart?repos=blax-software/<repo>&type=date&legend=top-left" /> + </picture> +</a> +``` + +### Notes on each anchor + +1. **OSS banner** — always the very first thing in the file, no blank + line before the H1. Linked back to the blax-software org. The SVG is + served from `laravel-workkit/art/oss-initiative-banner.svg` so all + packages share one source of truth. +2. **Title + badges** — title-case, no package-y suffix ("Laravel Roles", + not "Laravel Roles Package"). Badges sit directly under the H1 (no + intervening prose). See "Badges" below for what badges to use. +3. **Emoji feature list** — this is the section consumers skim hardest. + One bullet per line, format `- <emoji> **<short bold title>** — <one-line + benefit>`. Lead with the most compelling features. laravel-shop is the + gold-standard reference. Don't nest sub-bullets; if you need more + detail, link out to a section further down. +7. **Star History** — the star-history.com embed scoped to this repo, + always the very last thing. Update the repo slug in all four + occurrences. + +### Badges (anchor 2 detail) + +There is no fixed badge set. **Use the badges that are common in this +repo's stack**: + +- Laravel composer package → PHP version, Laravel version, License, and + optionally Packagist version + a Tests CI badge once the workflow + exists. +- Nuxt / Vue project → Node version, framework version, npm version, + build status, etc. +- Minecraft plugin → the relevant ecosystem badges (Spigot/Paper version, + bStats, etc.). + +The rule: pick what a visitor from that ecosystem expects to see — not a +fixed prescription. Don't ship a badge that's broken (e.g. a Tests CI +badge pointing at a workflow that doesn't exist yet). + +### Forbidden + +- A blank line between the OSS banner and the H1. +- "Click here", "More info"-style filler links. +- An "About" section before the features — the one-line description + above the features list is enough. +- Marketing emojis in section headings. The features list is the only + place emojis live. + +--- + +## 3. Cross-cutting principles + +These apply to **every** Blax composer package, regardless of stack. + +### UUIDs or ULIDs for everything + +Primary keys are always sortable, non-sequential identifiers — **either +UUIDv4 or ULID**. Integer auto-increments are forbidden in package +schemas. Foreign keys use `foreignUuid(...)` / `foreignUlid(...)`, +polymorphic relations use `uuidMorphs(...)` / `ulidMorphs(...)`. Pick one +style per package and stick with it; don't mix UUIDs and ULIDs in the +same package. + +Why: consumer projects in the Blax fleet are UUID/ULID-based (see +[[blax-laravel-conventions]]). A package that returned a `bigint` PK +would force the host to use `morphs()` instead of `uuidMorphs()` to +attach things to it, breaking the host's schema convention. + +### Model bindings via config + +Every model the package owns is bound in the service provider through a +`<package>.models.*` config key, e.g.: + +```php +// config/<package>.php +'models' => [ + 'product' => \Blax\Shop\Models\Product::class, +], + +// <Package>ServiceProvider::register() +$this->app->bind( + \Blax\Shop\Models\Product::class, + fn ($app) => $app->make($app->config['shop.models.product']) +); +``` + +This lets a consumer extend the package's model (add casts, scopes, +methods) and rebind via config without forking the package. Every +internal reference inside the package must resolve through the container +(`app(Product::class)`, dependency injection, etc.) — never `new +Product()` or `Product::query()` directly. + +Reference implementations: [laravel-roles/src/RolesServiceProvider.php:91-100](/home/a6a2f5842/Documents/Repos/laravel-roles/src/RolesServiceProvider.php#L91-L100), +[laravel-addresses/src/AddressesServiceProvider.php:149-165](/home/a6a2f5842/Documents/Repos/laravel-addresses/src/AddressesServiceProvider.php#L149-L165). + +### Backward compatibility + +Every release of a Blax package must be backward-compatible with the +previous minor version. Consumers must be able to `composer update` and +keep running without code changes. + +Concretely: + +- **Schema changes are additive only.** New columns, new tables, new + indexes are fine — but never drop or rename an existing column, never + rename a table, never narrow a type. If you absolutely must, deprecate + first and remove only on a major version bump. +- **Public PHP API is stable.** No removing methods, no renaming + classes, no narrowing parameter types or widening return types in + surprising ways. Add new methods rather than changing signatures. +- **Config keys never disappear.** New keys are fine and get sensible + defaults via `mergeConfigFrom`. Existing keys keep working forever — + if they become obsolete, the package ignores them, doesn't error. +- **Events, traits, contracts** carry the same stability guarantee as + public methods. + +Why: every internal Blax project pins package versions as `dev-master` +(see [[blax-laravel-conventions]]). A breaking change to a package +breaks every project on the next `composer update`. Treat every push to +`master` as a potentially-shipped release. + +### Naming: composer name, PHP namespace, README title + +These three labels live in different files but tell the same story — +keep them aligned. + +- **Composer package name** — `blax-software/laravel-<name>` for Laravel + composer packages. Universal across the fleet (laravel-roles, + laravel-shop, laravel-addresses, laravel-files, laravel-mail, + laravel-websockets, laravel-workkit). For non-Laravel packages drop + the `laravel-` prefix and use the relevant ecosystem prefix. +- **PHP namespace** — `Blax\<PackageName>` (e.g. `Blax\Shop`, + `Blax\Roles`, `Blax\Addresses`). The *original* intent was the longer + `BlaxSoftware\Laravel<PackageName>` form, but in practice all but one + package settled on the short `Blax\<PackageName>` form, so that's the + working standard for new packages. The one outlier + (`BlaxSoftware\LaravelWebSockets`) is grandfathered — don't migrate + it. +- **README H1 title** — just the nice human-readable name of the + package. If it's a Laravel package, prefix with `Laravel`. **No + "Package" suffix.** So: `# Laravel Shop`, `# Laravel Roles`, + `# Laravel Mail`. Not `# Laravel Shop Package`. + +### Money in integer cents — never floats + +Monetary columns are stored as **integer cents** (or the equivalent smallest +currency unit). Never `decimal`, never `float`. The package's casts mark +them `'integer'`, the migrations declare them `integer` (or +`unsignedBigInteger` for large totals like Stripe's `amount_capturable`). + +Why: float arithmetic is lossy in non-obvious ways (`0.1 + 0.2 !== 0.3`). +A `decimal` column avoids the float problem at the storage layer but +re-introduces it the moment a value leaves the DB into PHP. Integers +sidestep both. The formatting step (cents → "€19.99") happens at the +*presentation* boundary — never in the model, the service, or the DB. + +Currency is a separate column (`currency`, ISO 4217), never inferred from +the integer. + +Reference: `Blax\Shop\Models\ProductPrice::$casts` has `unit_amount`, +`sale_unit_amount`, and tier `unit_amount`/`flat_amount` all cast as +`'integer'`. + +### Atomic conditional UPDATEs over `lockForUpdate` dances + +When you need to decrement a counter race-safely (stock, balance, available +seats), prefer a single atomic conditional UPDATE over a transaction + +`lockForUpdate` + check + update. + +```php +// ✅ Atomic — one statement, race-safe +$affected = static::whereKey($this->getKey()) + ->where('available_copies', '>=', $quantity) + ->update(['available_copies' => DB::raw( + 'available_copies - '.(int) $quantity + )]); + +return $affected > 0; + +// ❌ Transactional dance — three statements, locks the row, more code +DB::transaction(function () use ($id, $quantity) { + $row = static::whereKey($id)->lockForUpdate()->first(); + if ($row->available_copies < $quantity) { + throw new NotAvailableException(); + } + $row->decrement('available_copies', $quantity); +}); +``` + +The atomic form returns the same race-safety guarantee with no transaction +and no row lock — the database honours the `WHERE` and the `UPDATE` +together. If 0 rows match, you know the constraint was violated and your +caller decides how to translate that into a 422 / exception / fallback. + +Why: simpler code, fewer round-trips, no transaction state to manage. The +atomic form also composes better in queue jobs and serverless contexts +where transaction lifetimes are dicey. + +Use the transactional form only when you genuinely need multi-row consistency +(e.g. "decrement stock AND insert order line item — both or neither"). In +that case the transaction stays small and only wraps the multi-step work. + +### Automatic updates — no user action for migration updates + +When a package author ships a new migration, a consumer must be able to +get it by running just: + +```bash +composer update +php artisan migrate +``` + +No `vendor:publish` step. No manual file copying. No "edit your +migration to add this column" instructions in the changelog. The hybrid +migration pattern (section 1) is what makes this work — `loadMigrationsFrom` +picks up new files from `vendor/` automatically, and the additive-only +schema rule above guarantees the new migration won't break existing +data. + +This is what separates a "Blax-grade" package from a typical +Laravel-ecosystem package that requires `php artisan +vendor:publish --tag=foo-migrations` after every upgrade. + +--- + +## 4. Subclassable models: every relation declares its foreign key + +If your package's model is meant to be subclassed by consumers (a host app's +`Book extends Product`, `Invoice extends Document`, …), **every `hasMany`, +`hasOne`, and `belongsToMany` on that model must declare the foreign key +explicitly**. Don't rely on Eloquent's convention to infer it from the +parent class name. + +```php +// ✅ Explicit — survives subclassing +public function stocks(): HasMany +{ + return $this->hasMany( + config('shop.models.product_stock', ProductStock::class), + 'product_id' + ); +} + +// ❌ Convention-driven — breaks the moment a consumer extends +public function stocks(): HasMany +{ + return $this->hasMany(ProductStock::class); + // When called on Book extends Product, Eloquent guesses `book_id` + // and the relation either errors (no such column) or silently + // returns an empty collection. +} +``` + +This is the most common way a package "appears to support subclassing" but +silently breaks for consumers. Subclassing is the canonical Laravel +extensibility mechanism — far simpler than wrappers, decorators, or +service rebinding — but a single un-prefixed FK on a hasMany ruins it. + +The same rule applies to: + +- `hasMany` / `hasOne` — pass `'parent_id'` (or whatever the actual column + is) as the second argument. +- `belongsToMany` — pass the pivot table name and both FK columns + explicitly, since the pivot name is *also* inferred from the class. +- Polymorphic morphs (`morphMany`, `morphTo`) are safe — they use the + `*_type` / `*_id` columns directly, not the class name. + +Tests for this principle: + +- Spin up a bare subclass in a test fixture (`class SubclassedProduct + extends Product {}`) and assert each relation returns rows. If the FK + was inferred from the subclass name, the assertion fails on the insert + or the select. + +Reference: [Blax\Shop\Models\Product::attributes(), actions()](/home/a6a2f5842/Documents/Repos/laravel-shop/src/Models/Product.php), [Blax\Shop\Traits\HasStocks::stocks(), allStocks()](/home/a6a2f5842/Documents/Repos/laravel-shop/src/Traits/HasStocks.php), [tests/Feature/Product/ProductSubclassFkTest.php](/home/a6a2f5842/Documents/Repos/laravel-shop/tests/Feature/Product/ProductSubclassFkTest.php) — the regression test built specifically for this rule. + +Why: a Blax package's value is amplified by being trivially extensible. +A library that wants to use `laravel-shop` shouldn't model `Book` next +to `Product`; it should `class Book extends Product` and gain stocks / +prices / categories / actions for free. That only works if the relations +keep pointing at `product_id` regardless of the calling subclass. + +--- + +## 5. Domain data lives in tables, policy knobs live in config + +Anything that varies **per-record** belongs in a table. Anything that +applies **app-wide** belongs in config. Don't blur the line. + +| Belongs in config | Belongs in a table | +|---|---| +| Default loan duration in weeks | The actual due-date of each loan | +| Maximum extensions allowed | This loan's count of extensions used | +| Whether Stripe is enabled | A product's price | +| Cart expiration window | A cart's expiry timestamp | +| Currency code default | An order's actual currency | +| Whether to auto-publish migrations | What columns a table has | + +The wrong answer: storing per-product pricing tiers in +`config('shop.loan.pricing')` — every product has to share one ladder, +host apps can't differentiate, and the data is uneditable through the +admin UI. The right answer is a `product_price_tiers` table with one row +per tier, FK to `product_prices`. + +The "config-vs-data" smell test: ask "can two records sensibly disagree +about this value?" If yes, it's data. If no, it's config. + +Edge case — **defaults that policy can override**: the default loan +duration sits in config (`shop.loan.default_duration_weeks = 2`) but a +specific borrower might have a 4-week limit (data, on the user record). The +loan creation logic reads config as the floor, then lets per-record data +override. Both layers coexist, neither "wins" — config is the policy, +data is the exception. + +Reference: [Blax\Shop\Models\ProductPriceTier](/home/a6a2f5842/Documents/Repos/laravel-shop/src/Models/ProductPriceTier.php) — pricing as data; +[config('shop.loan')](/home/a6a2f5842/Documents/Repos/laravel-shop/config/shop.php) — duration / extension policy as config. + +--- + +## 6. Lifecycle traits split fat models + +When a model accumulates 200+ lines of methods around one domain concept +(booking lifecycle, loan lifecycle, audit log, soft archival …), extract +that concept into a **domain-named trait** named after the *concept*, not +the model. + +```php +// ✅ Concept-named trait — co-located, importable, separately testable +use HasBookingLifecycle, HasLoanLifecycle; + +// ❌ Bag-of-traits with model-derived names — fans out infinitely +use ProductPurchaseScopes, ProductPurchaseMethods, ProductPurchaseHelpers; +``` + +The good trait names describe what they do (booking lifecycle, loan +lifecycle); the bad ones just describe where they came from +(ProductPurchaseScopes). The first style stays useful when another model +needs the same behavior — `HasLoanLifecycle` could attach to a future +`Subscription` model too. The second is impossible to lift out. + +Rules of thumb: + +- **One concept per trait.** If you can't describe the trait in one + sentence ("loan extension / return semantics on a purchase row"), it's + doing too much. Split. +- **Unit-test the trait directly.** If the trait can only be tested via + the host model's integration paths, the trait has hidden coupling. The + unit test for a lifecycle trait should be able to spin up a bare model + + trait and exercise the methods. +- **Co-locate scopes with the methods that use the same domain meta + keys.** A scope reading `meta->returned_at` belongs next to the method + that writes `meta.returned_at`. +- **Don't move the host's `protected $casts` or `$fillable`** into the + trait. Those stay on the model — the trait declares *behavior*, not + *schema*. + +Reference: [Blax\Shop\Traits\HasBookingLifecycle](/home/a6a2f5842/Documents/Repos/laravel-shop/src/Traits/HasBookingLifecycle.php), [Blax\Shop\Traits\HasLoanLifecycle](/home/a6a2f5842/Documents/Repos/laravel-shop/src/Traits/HasLoanLifecycle.php) — extracted from `ProductPurchase` so the model declares its data shape and composes its behavior. + +--- + +## 7. API resource translators decouple internal vocabulary from public contracts + +Eloquent column names follow the package's internal vocabulary — +e-commerce in `laravel-shop`'s case (`from`, `until`, `amount_paid`, +`purchasable_*`). Direct serialization leaks that vocabulary into every +host app's API and into every external integration. That's a coupling no +host wants. + +**The package ships a base `JsonResource` that translates internal names +to domain-flavored names**, with override hooks for the parts a host +inevitably needs to customize. + +```php +// In the package — ships the base translator +class PurchaseResource extends JsonResource +{ + public function toArray($request): array + { + return [ + 'id' => $this->id, + 'item' => $this->resolveItem(), + 'loaned_at' => optional($this->from)->toIso8601String(), // ← `from` → `loaned_at` + 'due_at' => optional($this->until)->toIso8601String(), // ← `until` → `due_at` + 'returned_at' => $this->returnedAt(), + 'status' => $this->getDomainStatus(), // ← derived + 'accrued_cost' => $this->from ? $this->accruedCost() : null, + ]; + } + + // Hook for host apps to point at their own nested resource. + protected function purchasableResource(): ?string { return null; } +} + +// In the host app — minimal subclass for domain vocabulary +class LoanResource extends PurchaseResource +{ + public function toArray($request): array + { + $payload = parent::toArray($request); + $payload['book'] = $payload['item']; // rename per domain + unset($payload['item']); + return $payload; + } + + protected function purchasableResource(): ?string + { + return BookResource::class; // point at app resource + } +} +``` + +Rules: + +- **Never serialize the model directly.** A bare `Resource::make($model)` + with no translation layer ships the package's column names to the + caller — change those names and every consumer breaks. The translator + is the contract. +- **The translator name describes the domain output, not the source + model.** `PurchaseResource` is fine; `ProductPurchaseResource` is fine; + but if the resource is loan-flavored, name it `LoanResource` and have + it translate. +- **Hooks for subclasses are explicit methods, not protected attributes + on the resource.** A `purchasableResource()` method is overridable; a + `$purchasableResource = …` property is one Eloquent quirk away from + not working. + +Reference: [Blax\Shop\Http\Resources\PurchaseResource](/home/a6a2f5842/Documents/Repos/laravel-shop/src/Http/Resources/PurchaseResource.php) — the package translator. [App\Http\Resources\LoanResource](/home/a6a2f5842/Documents/Repos/moonshiner-library/app/Http/Resources/LoanResource.php) — the moonshiner library's domain subclass. + +Why: it's the only practical way to refactor internal column names without +a breaking-change release. The package can rename `until` to `valid_until` +in a major version, and the translator absorbs the rename — consumers +don't notice. + +--- + +## Checklist for a new Blax Laravel package + +- [ ] `database/migrations/` contains real `.php` files (no `.stub`), + timestamped from the package's first-release date. +- [ ] Service provider auto-loads via `loadMigrationsFrom` and offers + filename-preserving publishing. +- [ ] `config/<package>.php` exposes `run_migrations` (default true). +- [ ] Every `Schema::create` is guarded by `hasTable`, every column + addition by `hasColumn`. +- [ ] README has the 4 mandatory anchors in order: OSS banner → + title+badges → emoji feature list → … → Star History. +- [ ] No blank line between the OSS banner and the H1 title. +- [ ] Badges match the repo's stack (no broken badges like a CI badge + pointing at a missing workflow). +- [ ] `composer require` + `php artisan migrate` is the *complete* install + flow for the happy path. +- [ ] Composer name is `blax-software/laravel-<name>` (or stack-equivalent + prefix for non-Laravel packages). +- [ ] PHP namespace is `Blax\<PackageName>`. +- [ ] README H1 is `# Laravel <Name>` (no "Package" suffix) for Laravel + packages, just `# <Name>` otherwise. +- [ ] All primary keys are UUIDs or ULIDs (never integer auto-increments). +- [ ] Every package-owned model is bound via `<package>.models.*` config + and resolved through the container, never `new` or static calls + that bypass binding. +- [ ] The release is backward-compatible: no dropped columns / tables / + methods / config keys, schema changes are additive only. +- [ ] `composer update` + `php artisan migrate` is the *complete* upgrade + flow — no `vendor:publish` step required for migration updates. +- [ ] Money columns are integer cents (never `decimal`, never `float`), + currency is a separate `string(3)` column. +- [ ] Counter-decrement paths use atomic conditional UPDATEs; transactional + `lockForUpdate` only appears where multi-row consistency demands it. +- [ ] Every `hasMany` / `hasOne` / `belongsToMany` on a model that's + intended to be subclassable declares its foreign key explicitly. + A regression test exercises a bare subclass through each relation. +- [ ] Per-record data lives in tables; app-wide policy lives in config. + Pricing tiers, due dates, statuses, currencies → tables. Default + durations, expiration windows, feature flags → config. +- [ ] Domain behavior on models lives in concept-named traits (e.g. + `HasLoanLifecycle`), unit-testable in isolation, never named after + the host model (no `ProductPurchaseScopes`). +- [ ] The package ships a `JsonResource` translator for each model + exposed via API, so host apps subclass for domain vocabulary + without leaking internal column names through the API boundary.