Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- Vendor-scoped `Str` macros `Str::simtabiLacommerceSku()`, `Str::simtabiLacommerceOrderNumber()` and
`Str::simtabiLacommerceTicketNumber()`, taking the same arguments as the bare macros they replace.
- Config key `register_legacy_macros` (default `true`). Set it to `false` to stop registering the bare macros.
- Config key `generator.<name>.prefix` (default `null`) on the `sku`, `ticket_number` and `order_number` blocks.
When set, it leads every value the trait generates: `ACME-BLU-8056449213`. For order numbers it replaces the
default `ORD`. A published config without the key behaves as `null`.
- An optional `$prefix` third argument on `Identifiers::sku()` and `Identifiers::ticketNumber()`, and on
`Supports\Helpers::makeRandomString()`.
- The shipped `SkuGenerator`, `OrderNumberGenerator` and `TicketNumberGenerator` implement the per-type
interface (`SkuGeneratorInterface`, …) the container resolves them through. They extended only the base
`GeneratorInterface` before.

### Changed

- The `HasSku`, `HasOrderNumber` and `HasTicketNumber` generators call `Identifiers` directly instead of the
bare `Str` macro. A package or application that registers its own `Str::sku()` no longer changes what
your models generate. A custom generator naming another `$strMixin` still has that macro called.
- The observer calls `render()` on the generator instead of casting it to a string. Output is unchanged for the
shipped generators, whose `__toString()` returned `render()`.
- A `generator` config key naming a class that does not implement `GeneratorInterface` now throws
`InvalidOptionException` naming the key on the first save. It threw a `TypeError` from the observer before.
- `InvalidOptionException::invalidArgument()` takes an optional `$code` (default `500`), and `render()` takes
`Illuminate\Http\Request`. It used to drop the code its one caller passed, leaving `0`, and type-hint the
`Request` facade, which a real request never is.

### Deprecated

Expand All @@ -28,6 +43,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
same arguments and output, forwarding to the scoped macros, and raise one `E_USER_DEPRECATED` per name per
boot. Earliest removal: 0.2.0.

### Fixed

- `Supports\Helpers::makeRandomString()` read a `$prefix` variable it never declared. The body was moved out of
the 0.1.0 `Str` macros in 2022, where `$prefix` was a closure parameter, and the parameter was left behind.
`empty()` of an undefined variable raises nothing, so the prefix branch could never run. It is now a third
parameter, `?string $prefix = null`. Output for every existing call is unchanged: no caller passed a prefix.
A call that does gets `PREFIX-SOURCE-DIGITS`, upper-cased. A prefix of `'0'` is kept; `null` and `''` add
nothing.
- `docs/tools/generators.md` told you to extend `SkuGenerator`, which is final, so its custom-generator example
could not compile. It now documents the two seams that work, extending the non-final
`Generators\Services\Generator` base or implementing the interface, and both examples are test fixtures run
against a model. A test fails if the documented example stops matching its fixture.
- A custom generator that implemented `GeneratorInterface` without a `__toString()` threw
`Object ... could not be converted to string` on every save.
- `Configs::getPrefix()`, and `skuConfig('prefix')` and its siblings, threw "must not be accessed before
initialization" unless `setPrefix()` had been called. The prefix now defaults to `null`.

## [0.1.0] - 2026-10-05

The first tagged release. An entry dated 2022-02-03 used to sit here as `0.1.0`, but no tag was ever
Expand Down
20 changes: 20 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,23 @@ arguments:
configured `generator.default.separator`. Once nothing calls the bare names, set `register_legacy_macros` to
`false` in `config/simtabi/lacommerce.php`.

### Custom generators

The documentation used to tell you to extend `SkuGenerator`. It is final, so no working code did that. Extend
`Simtabi\Lacommerce\Generators\Services\Generator` instead, or implement the interface; see
[Custom generators](docs/tools/generators.md#custom-generators).

Two things changed for a custom generator that already works:

- The observer calls `render()` rather than casting the generator to a string. A class whose `__toString()`
returned something other than `render()` now has `render()` used.
- A `generator` key naming a class that does not implement `GeneratorInterface` throws
`InvalidOptionException` naming the key, instead of a `TypeError`.

### Prefixes

Each generator block takes a new `prefix` key. A config published before this release does not have it, which
is the same as `null`: values are generated exactly as before. Add it to the block to prefix the values.
`Identifiers::sku()` and `Identifiers::ticketNumber()` take the prefix as an optional third argument. The `Str`
macros still ignore their `$prefix` argument for SKUs and ticket numbers, as 0.1.0 did.

24 changes: 21 additions & 3 deletions config/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,20 @@
|
*/
'sku' => [
/** Generator and must @implements GeneratorInterface */
/**
* Generator class. Built as `new $class($model)`, it must implement GeneratorInterface;
* extend Generators\Services\Generator to customise one. See docs/tools/generators.md.
*/
'generator' => SkuGenerator::class,

/** Source field(column) */
'source_column' => 'name',

/** Destination field(column) */
'destination_column' => 'sku',

/** Optional leading part, e.g. 'ACME' gives ACME-LAR-8056449213 */
'prefix' => null,
],

/*
Expand All @@ -71,14 +77,20 @@
|
*/
'ticket_number' => [
/** Generator and must @implements GeneratorInterface */
/**
* Generator class. Built as `new $class($model)`, it must implement GeneratorInterface;
* extend Generators\Services\Generator to customise one. See docs/tools/generators.md.
*/
'generator' => TicketNumberGenerator::class,

/** Source field(column) */
'source_column' => 'name',

/** Destination field(column) */
'destination_column' => 'ticket_number',

/** Optional leading part, e.g. 'HD' gives HD-SUP-1749302865 */
'prefix' => null,
],

/*
Expand All @@ -88,14 +100,20 @@
|
*/
'order_number' => [
/** Generator and must @implements GeneratorInterface */
/**
* Generator class. Built as `new $class($model)`, it must implement GeneratorInterface;
* extend Generators\Services\Generator to customise one. See docs/tools/generators.md.
*/
'generator' => OrderNumberGenerator::class,

/** Source field(column) */
'source_column' => 'name',

/** Destination field(column) */
'destination_column' => 'order_number',

/** Replaces the default ORD prefix when set, e.g. 'INV' gives INV-3920571846 */
'prefix' => null,
],
],

Expand Down
15 changes: 10 additions & 5 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,23 @@ How a generated value is produced on save. See the [Documentation index](../READ
- **Observer** (`Generators/Services/Observer`) — hooks the model's create/update events and asks the
generator for a value.
- **Generator** (`Generators/Services/Generator` + per-type `Concerns/*Generator`) — builds the value from
the configured source column(s) + separator; enforces uniqueness when required. Custom generators extend
these (see [Generators](tools/generators.md)).
the configured prefix, source column(s) and separator; enforces uniqueness when required. The per-type
generators are final; a custom generator extends the non-final `Generator` base or implements the
interface (see [Generators](tools/generators.md#custom-generators)).
- **Configs** (`Generators/Services/Configs`) — merges the `generator.default` block with the per-generator
block and any per-model overrides.
- **Contracts** (`Generators/Contracts/*GeneratorInterface`) — the interface a custom generator implements.
- **Contracts** (`Generators/Contracts/*GeneratorInterface`) — the per-type interfaces the container resolves
a generator through. Each extends `Generators/Services/Contracts/GeneratorInterface`, whose `render()` the
observer calls. The provider builds the class a config block names as `new $class($model)` and rejects one
that does not implement `GeneratorInterface`.

## Flow

1. A model using a generator trait is saved.
2. The observer fires on create (and on update when `refresh_on_update` is set).
3. The generator reads the source column(s), joins them with the separator, and — if `unique` — ensures no
collision, writing the result to the destination column.
3. The observer calls the generator's `render()`. The shipped generators read the source column(s), join them
with the separator, lead with the prefix when one is configured, and — if `unique` — retry until the
value is not already in the destination column. The observer writes the result there.

---

Expand Down
6 changes: 5 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,21 @@ return [
'generator' => SkuGenerator::class, // must implement GeneratorInterface
'source_column' => 'name', // source column(s)
'destination_column' => 'sku', // destination column
'prefix' => null, // optional leading part
],

'ticket_number' => [
'generator' => TicketNumberGenerator::class,
'source_column' => 'name',
'destination_column' => 'ticket_number',
'prefix' => null,
],

'order_number' => [
'generator' => OrderNumberGenerator::class,
'source_column' => 'name',
'destination_column' => 'order_number',
'prefix' => null, // replaces the default ORD
],
],
];
Expand All @@ -50,9 +53,10 @@ return [
| `generator.default.unique` | Enforce the generated value is unique. |
| `generator.default.generate_on_create` | Generate when the model is created. |
| `generator.default.refresh_on_update` | Regenerate when the model is updated. |
| `generator.<name>.generator` | The generator class (must implement its `GeneratorInterface`). |
| `generator.<name>.generator` | The generator class. Built as `new $class($model)`; it must implement `GeneratorInterface`, or the first save throws `InvalidOptionException`. See [Custom generators](tools/generators.md#custom-generators). |
| `generator.<name>.source_column` | Source column(s) the value is derived from. |
| `generator.<name>.destination_column` | Column the generated value is written to. |
| `generator.<name>.prefix` | Optional leading part of the value (default `null`, none). For `order_number` it replaces `ORD`. A published file without the key behaves as `null`. See [Prefixes](tools/generators.md#prefixes). |

Override any of these per model via the trait's config method — see [Generators](tools/generators.md).

Expand Down
120 changes: 101 additions & 19 deletions docs/tools/generators.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,20 @@ use Simtabi\Lacommerce\Supports\Identifiers;

Identifiers::sku('Laravel is Awesome'); // "LAR-8056449213"
Identifiers::sku('Laravel is Awesome', '_'); // "LAR_8056449213"
Identifiers::sku('Laravel is Awesome', '-', 'acme'); // "ACME-LAR-8056449213"
Identifiers::orderNumber(); // "ORD-3920571846"
Identifiers::orderNumber('INV', '/'); // "INV/3920571846"
Identifiers::ticketNumber('Support request'); // "SUP-1749302865"
```

| Method | Returns |
|--------|---------|
| `sku(string $source, string $separator = '-')` | First three characters of the studly-cased source, the separator, ten random digits, upper-cased. |
| `sku(string $source, string $separator = '-', ?string $prefix = null)` | The prefix when given, then the first three characters of the studly-cased source, then ten random digits, joined by the separator and upper-cased. |
| `orderNumber(?string $prefix = null, string $separator = '-')` | The prefix (`ORD` when empty), the separator, ten random digits, upper-cased. |
| `ticketNumber(string $source, string $separator = '-')` | As `sku()`. |
| `ticketNumber(string $source, string $separator = '-', ?string $prefix = null)` | As `sku()`. |

The separator is used as given. The traits pass the configured `generator.default.separator`. A value from
The separator is used as given. The traits pass the configured `generator.default.separator`, and the
`prefix` from the generator's config block (see [Prefixes](#prefixes)). A value from
`Identifiers` is not checked against your table; uniqueness is enforced by the traits' generators.

### `Str` macros
Expand All @@ -69,7 +71,9 @@ configured separator when you pass none:
| `Str::simtabiLacommerceOrderNumber(?string $source, ?string $separator = null, ?string $prefix = null)` | `Identifiers::orderNumber($prefix, …)`; `$source` is ignored |
| `Str::simtabiLacommerceTicketNumber(string $source, ?string $separator = null)` | `Identifiers::ticketNumber()` |

Each takes the same arguments as the bare macro it replaces, so migrating is a rename.
Each takes the same arguments as the bare macro it replaces, so migrating is a rename. The `sku` and
`ticketNumber` macros accept a third `$prefix` argument and ignore it, as the 0.1.0 macros did; to prefix a
SKU or ticket number, call `Identifiers` or set `prefix` in the config.

> The bare `Str::sku()`, `Str::orderNumber()` and `Str::ticketNumber()` from 0.1.0 are deprecated.
> `Str`'s macros are one flat map keyed by name, so another package or your application registering
Expand All @@ -96,7 +100,8 @@ class Product extends Model
{
return SkuConfigs::make()
->setSourceColumn(['id', 'user_id'])
->setDestinationColumn('order_number')
->setDestinationColumn('sku')
->setPrefix('ACME')
->setSeparator('-')
->forceUnique(true)
->generateOnCreate(true)
Expand All @@ -105,40 +110,117 @@ class Product extends Model
}
```

## Prefixes

Each generator's config block takes a `prefix`, `null` by default. When set, it leads the value:

| Block | `prefix` | Value |
|-------|----------|-------|
| `sku` | `'acme'` | `ACME-BLU-8056449213` |
| `ticket_number` | `'HD'` | `HD-PRI-1749302865` |
| `order_number` | `'INV'` | `INV-3920571846`; the prefix replaces the default `ORD` |

`setPrefix()` on a model's configs sets it per model, as in the example above. `getPrefix()` returns `null`
when none is set.

## Custom generators

For extra logic (a default value, a prefix, …) extend the base generator and override `getSourceString()`:
The shipped `SkuGenerator`, `OrderNumberGenerator` and `TicketNumberGenerator` are final, so they are not the
thing to extend. The provider builds whichever class a config block's `generator` key names as
`new $class($model)`, so a custom generator needs two things:

- a constructor that takes the model as its only argument, and
- `Simtabi\Lacommerce\Generators\Services\Contracts\GeneratorInterface`, whose one method, `render()`,
returns the value. Implement the per-type interface beside it in `Generators\Contracts`
(`SkuGeneratorInterface`, `OrderNumberGeneratorInterface`, `TicketNumberGeneratorInterface`), which extends
it, so the class is what the container says it resolves.

A class naming anything else fails on the first save with an `InvalidOptionException` that names the config
key.

### Extend the base generator

`Simtabi\Lacommerce\Generators\Services\Generator` is the base the shipped generators extend, and it is
built to be extended. Its parent constructor takes the model, the trait's config method (`skuConfigs`,
`orderNumberConfigs` or `ticketNumberConfigs`) and the identifier kind (`sku`, `orderNumber` or
`ticketNumber`). `render()` builds the source with `getSourceString()`, makes a candidate with `makeValue()`,
and, when `unique` is on, retries while `exists()` finds the candidate in the destination column. Override
whichever of those protected methods you need, and the rest, uniqueness included, keeps working.

This one falls back to a fixed source when the model's source columns are empty:

```php
namespace App\Components\SkuGenerator;
namespace App\Generators;

use Simtabi\Lacommerce\Generators\Concerns\Sku\SkuGenerator;
use Illuminate\Database\Eloquent\Model;
use Simtabi\Lacommerce\Generators\Contracts\SkuGeneratorInterface;
use Simtabi\Lacommerce\Generators\Services\Generator;

class CustomSkuGenerator extends SkuGenerator
final class FallbackSkuGenerator extends Generator implements SkuGeneratorInterface
{
public function __construct(Model $model)
{
parent::__construct($model, 'skuConfigs', 'sku');
}

protected function getSourceString(): string
{
$source = $this->modelConfig->sourceColumn;
$fields = array_filter($this->model->only($source));
$fields = array_filter($this->model->only($this->modelConfig->getSourceColumn()));

if (empty($fields)) {
return 'some-random-value-logic';
if ($fields === []) {
return 'item';
}

return implode($this->modelConfig->separator, $fields);
return implode($this->modelConfig->getSeparator(), $fields);
}
}
```

A product named `Blue shirt` still gets `BLU-8056449213`; one with an empty name gets `ITE-8056449213`.

### Implement the interface

When the value has nothing to do with the shipped format, implement the interface directly. You then own
uniqueness:

```php
namespace App\Generators;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use Simtabi\Lacommerce\Generators\Contracts\TicketNumberGeneratorInterface;

final class SlugTicketNumberGenerator implements TicketNumberGeneratorInterface
{
public function __construct(private readonly Model $model)
{
}

public function render(): string
{
return 'TKT-' . Str::upper(Str::slug((string) $this->model->getAttribute('name')));
}
}
```

Then point the config at it:
### Configure it

Name the class in the published `config/simtabi/lacommerce.php`:

```php
'generator' => \App\Components\SkuGenerator\CustomSkuGenerator::class,
'generator' => [
// ...
'sku' => [
'generator' => \App\Generators\FallbackSkuGenerator::class,
'source_column' => 'name',
'destination_column' => 'sku',
'prefix' => null,
],
],
```

A custom generator must implement `Simtabi\Lacommerce\Generators\Contracts\SkuGeneratorInterface` (or the
`OrderNumberGeneratorInterface` / `TicketNumberGeneratorInterface` beside it); extending the shipped generator
does that for you.
Both examples are the package's own test fixtures, run against a model on every build, and a test fails if
the first one here stops matching its fixture.

## About SKUs

Expand Down
7 changes: 6 additions & 1 deletion src/Generators/Concerns/OrderNumber/OrderNumberGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@
namespace Simtabi\Lacommerce\Generators\Concerns\OrderNumber;

use Illuminate\Database\Eloquent\Model;
use Simtabi\Lacommerce\Generators\Contracts\OrderNumberGeneratorInterface;
use Simtabi\Lacommerce\Generators\Services\Generator;

final class OrderNumberGenerator extends Generator
/**
* The shipped OrderNumber generator. Final: to customise it, extend Generators\Services\Generator or implement
* OrderNumberGeneratorInterface, and name your class in the config. See docs/tools/generators.md.
*/
final class OrderNumberGenerator extends Generator implements OrderNumberGeneratorInterface
{

public function __construct(Model $model)
Expand Down
Loading
Loading