nickdekruijk/webshop
A Laravel package offering the core building blocks for a simple webshop, such as products, carts and orders. It aims to integrate cleanly into existing Laravel applications while keeping store logic lightweight and easy to customize.
1. Install and constrain your stack first. 4.0.0 requires Laravel 11 and PHP 8.2+ (Laravel 11's floor). If you are still on Laravel 10, this release is a hard blocker — stay on 3.x until you upgrade the framework. Check with:
composer require mollie/laravel-mollie:^4.0
php artisan about # confirm framework/PHP versions after resolve
The service provider is auto-discovered; you should not need to touch config/app.php. Because Laravel 11 uses the slim skeleton, there is no app/Http/Kernel.php — anything you previously did there (CSRF exceptions for the webhook) now lives in bootstrap/app.php.
2. Publish config and set the key. Publish the package config, then wire the key through config/services.php-style env indirection rather than reading env() at call sites:
php artisan vendor:publish --provider="Mollie\Laravel\MollieServiceProvider"
MOLLIE_KEY=test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Use a test_ key everywhere except production. The single most common "why is my checkout broken" cause is a live key in a staging environment (or vice versa) — assert the prefix in a boot check or a health endpoint.
3. Where to look first. Resolve the client from the container and poke at it in php artisan tinker before writing any controller code:
use Mollie\Laravel\Facades\Mollie;
Mollie::api()->methods->allEnabled();
If that returns methods, your key, config cache, and HTTP layer are all fine. If it throws, the problem is configuration, not your payment code — which saves a lot of debugging time later.
4. First use case: a redirect checkout. This is the 80% path and the right thing to build first:
use Mollie\Laravel\Facades\Mollie;
$payment = Mollie::api()->payments->create([
'amount' => ['currency' => 'EUR', 'value' => '49.99'],
'description' => "Order #{$order->id}",
'redirectUrl' => route('checkout.return', $order),
'webhookUrl' => route('webhooks.mollie'),
'metadata' => ['order_id' => $order->id],
]);
$order->update(['mollie_payment_id' => $payment->id]);
return redirect($payment->getCheckoutUrl());
Note the split responsibility: the redirect is UX (the customer comes back and sees a thank-you page), the webhook is truth (it's what actually marks the order paid). Build both on day one — do not ship the redirect-only version "for now".
Treat the webhook as the only source of truth. The return URL can be abandoned, replayed, or spoofed. Always re-fetch the payment server-side and branch on its status:
Route::post('/webhooks/mollie', MollieWebhookController::class)
->name('webhooks.mollie')
->withoutMiddleware(\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class);
public function __invoke(Request $request)
{
$payment = Mollie::api()->payments->get($request->input('id'));
if (! $payment->isPaid()) {
return response()->noContent();
}
// metadata is echoed back verbatim — use it, but validate it
$order = Order::findOrFail($payment->metadata->order_id);
MarkOrderPaid::dispatch($order, $payment->id);
return response()->noContent();
}
Return 200 fast and push the real work to a queued job. Mollie retries on non-2xx, and a slow webhook handler means duplicate concurrent processing of the same order.
Make the webhook idempotent by design, not by hope. Mollie will deliver the same webhook more than once (retries, plus legitimate status transitions). Guard with a unique index or an atomic update:
$updated = Order::where('id', $order->id)
->whereNull('paid_at')
->update(['paid_at' => now(), 'status' => 'paid']);
if ($updated === 0) {
return; // already handled — this is the normal path, not an error
}
Do the same for refunds and chargebacks: they arrive on the same endpoint as separate entities, so the controller usually needs to branch on what the id resolves to (payments/get, refunds/get, chargebacks/get), or you register separate webhook URLs per resource.
Store the payment ID, always. Persist mollie_payment_id on your own row before redirecting. It is your only reliable handle for reconciliation, support ("customer says they paid"), and webhook lookups. Never rely on metadata alone, and never join back to Mollie by amount/description.
Use idempotency keys for create calls. Network timeouts on payments->create() mean you don't know whether a payment exists. Pass a deterministic key derived from your order so retries collapse:
Mollie::api()->payments->create($payload, ['idempotencyKey' => "order-{$order->id}-attempt-1"]);
This matters most in queued jobs and in any "retry payment" button — without it you generate duplicate payments that show up in the dashboard and confuse finance.
Model status as a state machine, not booleans. isPaid() is convenient but the interesting states are open, pending, authorized, canceled, expired, failed, charged_back. Keep a mapping table in your codebase (payment status → order status) and cover it with a test. authorized in particular is not paid: with Klarna and similar methods you must explicitly capture before the money moves.
Handle money as strings end-to-end. Mollie expects 'value' => '49.99', not 49.99. Compute in integer cents in your domain layer and format at the boundary:
'value' => number_format($order->total_cents / 100, 2, '.', '')
(string) ($cents / 100) will eventually produce 49.9 or scientific notation and the API will reject it — usually only for certain amounts, which is exactly the kind of bug that escapes staging.
Integrate via a thin wrapper, not the facade everywhere. Calling Mollie::api() directly from ten controllers makes testing painful and the 3.0 → 4.0 client migration a grep-hunt. Bind your own PaymentGateway interface and keep all Mollie client calls behind it. You get a seam for fakes and a single place to absorb future client changes.
Testing. There is no first-party Mollie::fake(), so either hit the API with a test_ key in a dedicated test suite (realistic, but requires network and produces dashboard noise) or bind a stub implementation of your gateway interface in the container for the fast suite. Do both: stub for the bulk of tests, one or two live test-mode tests to catch payload-shape regressions. For webhook tests, assert that the controller returns 200 and is safe to call twice — that's the property you actually care about.
Upgrade tip for the 3.0 client. The API client major bump is a real rewrite, not a rename. Before migrating, grep for every Mollie:: call site and inventory which endpoints you touch, then run the suite against a test key. Pay particular attention to request payload shapes (some endpoints now take typed value objects instead of plain arrays) and to exception classes, which were reorganised — a catch (\Mollie\Api\Exceptions\ApiException $e) that no longer matches will silently turn an API error into a 500.
composer why-not laravel/framework 11 before committing to it. There is no compatibility shim — it's a hard constraint, not a soft one.withoutMiddleware(VerifyCsrfToken::class) (or the Laravel 11 validateCsrfTokens(except: [...]) equivalent in bootstrap/app.php) produces a 419 for every webhook. Since Mollie sees a non-2xx it retries, and you'll get a wall of failures while orders sit unpaid. Test the endpoint with curl before going live.route() under APP_URL=http://localhost sends a useless URL to Mollie. Use APP_URL correctly per environment, and remember Mollie will not call http:// in production. A quick fix for local development is a tunnel (ngrok/Expose) — but don't forget that the webhook then points at your machine.config:cache bites. The package config is read once; if you rotate MOLLIE_KEY and forget php artisan config:clear, you'll chase ghosts. Add key-prefix validation to a deploy health check.metadata. It round-trips through the customer's browser session and the Mollie dashboard, and anyone with dashboard access can edit it. Use it as a lookup hint (Order::find) and validate that the found order's stored mollie_payment_id matches the incoming id. Skipping that check is a real privilege-escalation vector.Mollie\Api\Exceptions\ApiException for transport/4xx, and handle 404 specifically — a deleted or wrong-environment payment ID is a 404, not a generic failure. Log the request id from the exception; Mollie support can trace it, and it's the only useful identifier in a ticket.refunds->create() returns a refund in pending; the final state arrives via webhook. Don't mark an order refunded at the moment you call create, or your books will disagree with reality for a few seconds to a few days depending on method.RequestReceived/RequestTerminated listener that forgets the resolved client, or accept occasional cross-request state and debug it later at your peril.How can I help you explore Laravel packages today?