idci/code-generator-bundle
A Symfony bundle for generating PHP code from customizable templates. Define generators and templates, then produce classes, entities, or boilerplate through console commands or the service API. Configurable output and Symfony integration make it easy to automate repetitive code scaffolding.
First, calibrate expectations: this is not a Laravel package. idci/code-generator-bundle is a Symfony2 bundle — it ships a Bundle class, a DependencyInjection extension, and Resources/config/services.xml. There is no service provider, no facade, no publishable config, and the last meaningful compatibility target is a framework that reached end-of-life in 2018. With zero stars, zero dependents, and no CI, you are not adopting a dependency. You are reading a ~100-line algorithm and reimplementing it in Laravel. Budget half a day, not a sprint.
Wherever you start, start here:
src/, find the generator interface, the concrete strategies (alphanumeric/numeric), and how they are tagged and registered in Resources/config/services.xml. That tagged-service pattern is the only design idea worth carrying over — it maps cleanly onto Laravel's $this->app->tag() and tagged().composer require it. The Symfony 2.x and Doctrine 2.x pins will conflict with the Symfony components Laravel already depends on. The realistic outcomes are an unsatisfiable dependency graph or a forced downgrade of your framework. Neither is acceptable.app/Support/Codes/ and wire it with a normal service provider. Keep it as your own code, with your own tests, and your own ownership.Your first use case is almost always human-facing identifiers: coupon codes, invite codes, order references, referral slugs — the places where Str::random(10) gives users something impossible to read over the phone.
A minimal, portable core:
// app/Support/Codes/CodeGenerator.php
namespace App\Support\Codes;
interface CodeGenerator
{
public function generate(): string;
}
// app/Support/Codes/AlphanumericGenerator.php
final class AlphanumericGenerator implements CodeGenerator
{
public function __construct(
private readonly string $alphabet,
private readonly int $length,
) {}
public function generate(): string
{
$max = strlen($this->alphabet) - 1;
$code = '';
for ($i = 0; $i < $this->length; $i++) {
$code .= $this->alphabet[random_int(0, $max)];
}
return $code;
}
}
// config/code-generator.php
return [
'default' => 'alphanumeric',
'generators' => [
'alphanumeric' => [
// no 0/O/1/I/L — these get mistyped constantly and generate support tickets
'alphabet' => 'ABCDEFGHJKMNPQRSTUVWXYZ23456789',
'length' => 10,
],
'numeric' => ['length' => 8],
],
];
Bind it once and you have a working, framework-native replacement:
// app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->bind(CodeGenerator::class, function ($app) {
$config = config('code-generator.generators.'.config('code-generator.default'));
return new AlphanumericGenerator($config['alphabet'], $config['length']);
});
}
That is the whole "adoption" story. Everything below assumes you have made this port.
Bind the interface, never the concrete class. Everything downstream — jobs, controllers, tests, Artisan commands — types against CodeGenerator. Swapping the alphabet, the length, or the whole strategy then becomes a config change rather than a find-and-replace.
Config-driven strategies with a named default. The bundle's services.xml tagged-service approach translates directly:
$this->app->bind(CodeGenerator::class, fn ($app) => new AlphanumericGenerator(
config('code-generator.generators.'.config('code-generator.default').'.alphabet'),
config('code-generator.generators.'.config('code-generator.default').'.length'),
));
If you need multiple simultaneous generators (e.g. invite codes vs. order references), tag them and resolve by name — mirror the Symfony pattern rather than inventing one.
Enforce uniqueness in the database, not in memory. This is the single most important pattern and the one most likely to be wrong in an era-appropriate implementation. A SELECT-then-INSERT "already used?" check races under concurrency. Use a unique index plus a bounded retry loop:
// database/migrations/...
$table->string('code', 32)->unique();
final class CodeIssuer
{
public function __construct(private CodeGenerator $generator) {}
/** @template T
* @param callable(string): T $persist
* @return T
*/
public function issue(callable $persist, int $maxAttempts = 5): mixed
{
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
try {
return DB::transaction(fn () => $persist($this->generator->generate()));
} catch (UniqueConstraintViolationException $e) {
if ($attempt === $maxAttempts) {
throw $e;
}
usleep(random_int(1_000, 10_000));
}
}
}
}
Laravel 11 raises Illuminate\Database\UniqueConstraintViolationException for duplicate keys; on older versions catch QueryException and check the SQLSTATE 23000/23505. Either way, retries must be bounded — an unbounded loop turns a high-collision period into a queue stall.
Model hook for the common case. A HasCode trait keeps the issuer from leaking into every controller:
trait HasCode
{
protected static function bootHasCode(): void
{
static::creating(function (self $model) {
if (! $model->code) {
$model->code = app(CodeGenerator::class)->generate();
}
});
}
}
Add the unique index in the migration before shipping the trait. If you ship the trait first, you will get a duplicate code in staging within a week.
An Artisan command for bulk generation. The bundle exposes Symfony console commands; mirror that with an Artisan command. This is the day-two need — marketing asks for a batch of 5,000 codes:
class GenerateCodes extends Command
{
protected $signature = 'codes:generate {count} {--chunk=500}';
protected $description = 'Generate unique codes into the codes table';
public function handle(CodeIssuer $issuer): int
{
$count = (int) $this->argument('count');
$chunk = (int) $this->option('chunk');
$created = 0;
while ($created < $count) {
$rows = [];
for ($i = 0; $i < min($chunk, $count - $created); $i++) {
$rows[] = ['code' => app(CodeGenerator::class)->generate(), 'created_at' => now()];
}
// insertOrIgnore + retry on the residual gaps, or use $issuer
$created += DB::table('codes')->insertOrIgnore($rows);
}
$this->info("Generated {$created} codes");
return self::SUCCESS;
}
}
For large batches, prefer insertOrIgnore plus a retry pass over per-row inserts — per-row inserts against a unique index are the main source of deadlocks during bulk jobs.
Make the RNG injectable so you can test. The original class is likely to call mt_rand or uniqid directly. Even after you replace it with random_int, tests should not depend on real randomness:
interface RandomSource { public function int(int $min, int $max): int; }
final class NativeRandom implements RandomSource
{
public function int(int $min, int $max): int { return random_int($min, $max); }
}
Tests can then assert distribution over the alphabet and collision rejection deterministically, without flaking.
Config is the extension point. The bundle's "extension points" are really the alphabet, length, prefix/checksum, and registration. Reproduce them as config keys — alphabet, length, prefix, checksum — and keep the generator generic. Custom alphabets (base32 for case-insensitivity, digits-only for SMS) are a config array, not a new class.
Do not treat this as an installable dependency. Re-stating because it will cost you a day if you try: the Symfony 2.x pins will not co-resolve with a modern Laravel's Symfony components. If you copy the code, also copy the license header into your port and record its provenance — supply-chain reviews flag unvetted code even when it's vendored.
EOL runtime is a hard risk, not a footnote. Symfony 2 has been out of support since 2018, the PHP floor is 5.x, and any security issue in this generation path will never be patched upstream. After you port it, the code is your code — include it in your dependency-update cadence and your code review process.
Zero trust signals. No CI, no tests, no dependents, no CVE feed. Before you copy anything, read the RNG source. If you see mt_rand, rand, uniqid, str_shuffle, or array_rand in the generation path, replace it with random_int before the code leaves your editor. Predictable voucher or invite codes are an enumeration vector, not a style issue.
Uniqueness enforced in memory is a production bug waiting to happen. The most important thing to verify in the original source is whether it relies on a DB unique index or an in-application "already used?" check. The latter races under concurrency and produces duplicates exactly when traffic is highest. Your port must use the DB index — there is no acceptable alternative.
Case-insensitivity will bite you. MySQL's default utf8mb4_unicode_ci collation makes a1b2 and A1B2 collide in the unique index while your application treats them as different. Either fix the column to a binary/case-sensitive collation and normalise the alphabet, or accept the stricter constraint — but decide explicitly, and test it. Str::upper() on generation plus a case-sensitive collation is the simplest resolution.
Do the entropy math before you pick a length. ABCDEFGHJKMNPQRSTUVWXYZ23456789 is 31 characters; at length 10 that is ≈ 31^10 ≈ 8.2e14 — fine for low-volume invite codes, not fine for a public enumeration surface without rate limiting. Pick the length from the abuse model, not the aesthetics. Short codes for phone support and high entropy for public URLs are different problems.
Ambiguous characters are a support cost, not a style choice. Drop 0/O, 1/I/L, and ideally uppercase-only. Every ambiguous pair you keep generates a manual-entry failure and a support ticket. If you need digits and letters mixed, keep the dropped-character set in the config comment so the next engineer doesn't "helpfully" restore them.
Pre-generated pools scale with inventory, not usage. If your port (or the original) writes N codes into a table up front, a 10M-code campaign is a 10M-row table with a large unique index to maintain, purge, and archive. Consider generating on demand with the DB constraint and bounded retry instead — you pay in tail latency, not storage.
Bound the retry loop and instrument it. A retry loop that has never been observed is not a retry loop you understand. Log the attempt count and emit a metric on the final failure. If retries exhaust, the user sees a 500 — a rate you want to alert on before it becomes a support incident.
Deadlocks on bulk inserts are a concurrency shape issue, not a bug. Parallel single-row inserts against a unique index deadlock under load. Batch inserts (insertOrIgnore in chunks of 500–2000) and let a second pass close the residual gaps; the collision rate is what determines how many passes you need.
Test time-of-check-vs-time-of-use explicitly. A test that asserts "generated code is unique" against a single-threaded generator proves nothing. Simulate concurrency by firing parallel inserts (or a job that inserts the same code, expecting the retry path) and assert you get maxAttempts behaviour rather than a duplicate row.
Mirror the tagged-service extension point, not the Symfony config files. services.xml and the DependencyInjection extension are noise in a Laravel codebase. What transfers is the idea that generators are named, tagged services resolved by name — use $this->app->tag(), tagged(), and a CodeGeneratorFactory resolving from config by name.
Document the decision, not just the code. The value of reading this repository is the product policy it surfaces — alphabet, length, case sensitivity, checksum, collision retry — not the implementation. Write those decisions into the config/code-generator.php comments or a short README. The next person will thank you; otherwise they will re-derive them, or worse, change them silently.
How can I help you explore Laravel packages today?