700 lines
30 KiB
Markdown
700 lines
30 KiB
Markdown
|
|
# 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_<package>_tables.php
|
||
|
|
2025_01_01_000002_<additive_migration>.php
|
||
|
|
2026_04_26_000001_<later_additive_migration>.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** (`<Package>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 `<package>.run_migrations = false` for
|
||
|
|
* projects that prefer to publish + manage migrations themselves.
|
||
|
|
*/
|
||
|
|
protected function registerMigrations(): void
|
||
|
|
{
|
||
|
|
if (! config('<package>.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/<package>.php' => $this->app->configPath('<package>.php'),
|
||
|
|
], '<package>-config');
|
||
|
|
|
||
|
|
$migrationsPath = __DIR__ . '/../database/migrations';
|
||
|
|
$publishMap = [];
|
||
|
|
foreach (glob($migrationsPath . '/*.php') as $sourcePath) {
|
||
|
|
$publishMap[$sourcePath] = $this->app->databasePath('migrations/' . basename($sourcePath));
|
||
|
|
}
|
||
|
|
$this->publishes($publishMap, '<package>-migrations');
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Config key**
|
||
|
|
|
||
|
|
```php
|
||
|
|
// config/<package>.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
|
||
|
|
<!-- 1. OSS banner — mandatory, always first, no blank line before the H1 -->
|
||
|
|
[](https://github.com/blax-software)
|
||
|
|
|
||
|
|
<!-- 2. Title + badges — mandatory; title-case, no "Package" suffix -->
|
||
|
|
# <Title>
|
||
|
|
|
||
|
|
<!-- Pick badges that are common in this repo's stack — see "Badges" below -->
|
||
|
|
[](https://php.net)
|
||
|
|
[](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.
|