Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Webshop Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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".

Implementation Patterns

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.

Gotchas and Tips

  • Laravel 11 only is the headline breaking change. 4.0.0 drops Laravel 10 support. In a multi-package or monorepo setup this can force a coordinated upgrade; check composer why-not laravel/framework 11 before committing to it. There is no compatibility shim — it's a hard constraint, not a soft one.
  • CSRF on the webhook. Forgetting 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.
  • Webhook URLs must be public and absolute. 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.
  • Don't trust 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.
  • API exceptions come in flavours. Catch 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.
  • Rate limits and dashboards. Fetch-on-webhook is fine, but avoid polling Mollie on page loads or in list views. Cache payment status locally and refresh only on webhook or explicit user action. A "sync all orders" cron is a classic way to get throttled.
  • Refunds are asynchronous. 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.
  • Extension points. If you need custom HTTP behaviour (proxy, retry policy, logging), the client's adapter is swappable via the container — do it in a service provider rather than subclassing the facade. For per-tenant keys (marketplaces), resolve a fresh client instance per tenant instead of mutating the shared facade singleton; the singleton is memoised and will leak the first tenant's key into subsequent requests under Octane.
  • Octane and queued workers. Static/singleton state around the client is the usual Octane footgun. If you run Octane, add a RequestReceived/RequestTerminated listener that forgets the resolved client, or accept occasional cross-request state and debug it later at your peril.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin