mkurc1/wallet-bundle
A Symfony bundle for integrating virtual wallets into your application. It provides balance tracking, credit and debit operations, and transaction history through reusable entities and services, making it easy to add account funding, ledger, and payment-wallet features.
The 30-second reality check. mkurc1/wallet-bundle is a Symfony bundle, not a Laravel package. That means:
ServiceProvider, no facade, no config/wallet.php, no publishable migrations.Symfony\Component\HttpKernel\Bundle\Bundle, ships a DependencyInjection\*Extension, and almost certainly maps a Doctrine entity onto your User.composer require mkurc1/wallet-bundle in a Laravel app will install Symfony HTTP kernel/DI classes into vendor/ and then never boot anything. There is no auto-discovery path that connects it to Laravel's container.So for a Laravel developer the realistic first step is: treat the repo as reference source, not a dependency. Clone it, read it, port the schema and the invariants.
Where to look first, in this order:
composer.json — PHP and Symfony version constraints, and the autoload PSR-4 prefix. If it targets EOL PHP/Symfony, stop reading and build fresh; there is nothing to port that you can't design yourself in an hour.src/Entity/ (or src/Model/) — this is the most important file in the repo. It answers the only question that matters: is a wallet a single mutable balance column, or a set of wallet_transactions ledger rows? A balance column alone is toy-grade; a ledger is worth copying.src/Resources/config/services.* and src/DependencyInjection/Configuration.php — reveals whether balance mutation lives in a service (WalletManager, WalletService) or is done by the entity itself, and what the config tree exposes (currency, precision, per-user vs. multi-wallet).src/Resources/config/doctrine/*.orm.xml or entity annotations — the actual columns, indexes, and any @Version / optimistic-lock column.README.md — usually one paragraph of config/bundles.php registration. That's the "entire documentation."First use case you would actually ship — a points/credits wallet on App\Models\User, for demos, internal tools, or an MVP. Not real money. Concretely, three things ported:
wallets table (integer minor units, unique per user),HasWallet trait on User,WalletService that does the debit/credit inside a transaction with a row lock.That's the same three moving parts the bundle gives a Symfony app; the only difference is DI container vs. app(...) resolution.
Pattern 1 — Read-then-port, not adopt. The productive workflow is a 1–2 day spike: read src/Entity/ and the service, answer "ledger or balance column?", "is there locking?", "integer or float?", then write an Eloquent-native version. Keep the MIT header if you copy code.
Pattern 2 — Ledger-first schema. Port the ledger shape if the bundle has one; if it doesn't, still build one, because a bare balance column forces you to invent history later.
Schema::create('wallets', function (Blueprint $t) {
$t->id();
$t->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
$t->char('currency', 3)->default('USD');
$t->bigInteger('balance')->default(0); // minor units
$t->unsignedBigInteger('version')->default(0); // optimistic lock
$t->timestamps();
});
Schema::create('wallet_entries', function (Blueprint $t) {
$t->id();
$t->foreignId('wallet_id')->constrained()->cascadeOnDelete();
$t->bigInteger('amount'); // signed, minor units
$t->string('type'); // credit | debit | refund
$t->string('idempotency_key')->nullable()->unique();
$t->json('meta')->nullable();
$t->timestamps();
$t->index(['wallet_id', 'created_at']);
});
Pattern 3 — Trait on the user, mutations through the service. Mirror whatever trait/interface the bundle attaches to its User entity (usually HasWalletInterface + WalletTrait), but keep writes out of the model:
trait HasWallet
{
public function wallet(): MorphOne
{
return $this->morphOne(Wallet::class, 'walletable');
}
public function credit(int $amount, array $meta = []): WalletEntry
{
return app(WalletService::class)->credit($this, $amount, $meta);
}
public function debit(int $amount, array $meta = []): WalletEntry
{
return app(WalletService::class)->debit($this, $amount, $meta);
}
}
Pattern 4 — Atomic service, every time. Whatever the bundle does, reproduce the atomicity; the PHP-side $wallet->balance -= $x; $em->flush(); pattern (common in small bundles) is the bug you inherit.
final class WalletService
{
public function debit(Model $owner, int $amount, array $meta = []): WalletEntry
{
if ($amount <= 0) {
throw new InvalidArgumentException('Amount must be positive.');
}
return DB::transaction(function () use ($owner, $amount, $meta) {
$wallet = Wallet::where('user_id', $owner->id)->lockForUpdate()->firstOrFail();
if ($wallet->balance < $amount) {
throw new InsufficientFundsException();
}
$wallet->decrement('balance', $amount);
$wallet->increment('version');
return $wallet->entries()->create([
'amount' => -$amount,
'type' => 'debit',
'idempotency_key' => $meta['idempotency_key'] ?? null,
'meta' => $meta,
]);
});
}
}
Pattern 5 — Events as the extension point. Symfony would give you the event dispatcher; the bundle may dispatch wallet events. In Laravel, fire your own from the service so listeners handle receipts, notifications, and analytics without touching the wallet code:
DB::afterCommit(fn () => WalletDebited::dispatch($wallet, $entry));
Pattern 6 — Using it in a Symfony project (day job scenario). If you actually are in a Symfony app, the workflow is: config/bundles.php → App\WalletBundle::class => ['all' => true], import the config tree into config/packages/wallet.yaml, add the wallet trait/interface to your User entity, bin/console doctrine:schema:update --force, then inject the manager service (__construct(WalletManager $wallets)) and call credit()/debit(). The container wiring is the entire value-add; the domain logic is small enough to read in one sitting.
Pattern 7 — Test the invariants, not the bundle. Port these tests, because they're the ones that catch real failures: concurrent debits (two processes, one wallet, assert no negative balance), idempotency (same key twice → one entry), and a reconciliation job that asserts wallets.balance === SUM(wallet_entries.amount) per wallet.
composer require is a trap. The package installs cleanly and then does nothing in Laravel. Don't burn time on class_alias, container bridges, or the Doctrine bridge — read the source instead. A Doctrine + Eloquent dual-ORM app is an operational debt you don't want for a 7-star bundle.float or decimal(10,2), do not replicate it — use bigInteger minor units (or bcmath if you must do decimal math). Rounding drift is the bug that appears six months later and is the hardest to unwind.version / @Version column. If it's present, the author thought about concurrency — reproduce the optimistic-lock semantics alongside lockForUpdate(). If absent, the bundle offers no protection against double-spend and you must add it yourself.wallet:), consumed in Configuration.php. If you must inspect a running Symfony instance, bin/console config:dump-reference wallet and bin/console debug:container wallet are faster than reading the extension class. In Laravel there is no equivalent — you'd be writing config/wallet.php yourself.User entity with a OneToOne/OneToMany wallet mapping and a join column against your users table. If your Laravel users table has a UUID or non-id primary key, port the FK type deliberately (foreignUuid vs foreignId) — schema drift here is silent until a write fails.idempotency_key column from day one; retrofitting it after you have duplicate rows is painful.config/bundles.php? config key present? entity mapping loaded (doctrine:mapping:info)? migrations run? If any of those is missing the service resolves but writes fail silently at flush time. In Laravel, none of this applies — the package simply isn't running, no matter what the container says.lockForUpdate(), and eventually an event-sourced ledger with an async balance projection. Nothing in a bundle this small signals those concerns are addressed — assume you design them.How can I help you explore Laravel packages today?