# Online betalingen

Betalingen aanvaarden met Mollie in Laravel: een payment aanmaken, de klant terug ontvangen en webhooks lokaal testen met een tunnel.

Multimedia & Creatieve Technologie · 2.3 Advanced Web development · versie 1.0.0 · 2026-09-29

Tags: laravel, php, payments, webhooks

Slides: <https://slides.mctantwerp.be/nl/advanced-web-development/online-betalingen>

Speaker notes: <https://slides.mctantwerp.be/nl/advanced-web-development/online-betalingen.notes.md>

<!-- slide 2 -->

## Agenda

1. Online betalen
2. Het redirect-principe
3. Providers
4. Mollie in Laravel
5. Webhooks
6. Lokaal testen

<!-- slide 3 -->

## 1. Online betalen

<!-- slide 4 -->

**Je bouwt nooit zelf een betaalsysteem.**

Kaartnummers ontvangen, bewaren en met banken praten is een vak apart, met audits en aansprakelijkheid bij fraude.

<!-- slide 5 -->

### Zelf bouwen of een provider

#### Zelf bouwen

- PCI DSS-certificering, maanden werk en jaarlijkse audits
- Kaartgegevens zelf versleutelen en bewaren
- Een contract met elke bank en elke kaartmaatschappij
- Fraude is jouw probleem

#### Een payment provider

- Eén API voor tientallen betaalmethodes
- Zij regelen compliance, fraudedetectie en KYC
- Jij ziet nooit een kaartnummer
- Werkt binnen een paar uur, niet binnen een paar maanden

<!-- slide 6 -->

### Wie is wie

**Payment service provider (PSP)**: Het bedrijf dat de betaling uitvoert. Mollie, Stripe, Adyen, PayPal.

**Checkout**: De betaalpagina van de provider. Daar kiest de klant een methode en betaalt hij.

**PCI DSS**: Payment Card Industry Data Security Standard. De regels voor iedereen die kaartgegevens verwerkt, opslaat of doorgeeft.

<!-- slide 7 -->

### Wat de klant ziet

- **jouw site** Winkelmandje
- **jouw site** Klik op Betalen
- **mollie.com** Methode kiezen
- **app van de bank** Bevestigen
- **jouw site** Bedankt voor je bestelling

> [!NOTE]
> In stap 3 en 4 verlaat de klant je site. Jouw code wacht tot hij terugkomt.

<!-- slide 8 -->

## 2. Het redirect-principe

<!-- slide 9 -->

### De flow

1. Browser → Laravel, **Klik op "Betalen"**: Een formulier met een POST naar `/checkout`.
2. Laravel → Mollie, **Payment aanmaken**: Bedrag, omschrijving en een `redirectUrl`, met je API key erbij.
3. Mollie → Laravel, **Id en checkout url**: Een id zoals `tr_WDqYK6vllg` en een unieke url op mollie.com. Het id bewaar je bij je order.
4. Laravel → Browser, **Redirect naar de checkout**: Laravel stuurt de browser door. Vanaf nu is de klant weg van je site.
5. Browser → Mollie, **Klant betaalt**: Methode kiezen, bevestigen in de app van de bank, 3D Secure. Jouw app ziet hier niets van.
6. Mollie → Browser, **Redirect naar je redirectUrl**: Mollie stuurt de klant terug, of hij nu betaald heeft of niet.
7. Browser → Laravel, **GET /payment/return**: De klant is terug. Je weet nog niet of er betaald is.
8. Laravel → Mollie, **Status ophalen**: Met het bewaarde id: `paid`, `open`, `failed`, `canceled` of `expired`.
9. Laravel → Browser, **Bedankt, of opnieuw proberen**: Pas nu zet je de bestelling op betaald.

<!-- slide 10 -->

### Waarom die omweg

- Een kaartnummer komt nooit op jouw server, dus bij een hack lekt het ook niet
- Wie kaartgegevens verwerkt, valt onder PCI DSS. Door de redirect blijf je erbuiten.
- Mollie voegt Apple Pay toe, en jij verandert geen lijn code
- Mensen typen hun kaart liever op een pagina die ze kennen

<!-- slide 11 -->

### Hetzelfde patroon, overal

|   | De klant gaat naar | Komt terug met |
| --- | --- | --- |
| **Online betalen** | De checkout van Mollie of Stripe | Een payment id |
| **Log in met Google** | Het loginscherm van Google | Een `code`, in te ruilen voor een token |
| **itsme** | De itsme-app op zijn gsm | Een bevestigde identiteit |
| **3D Secure** | De app van zijn bank | Een goedgekeurde kaartbetaling |
| **Spotify, Slack, Shopify** | Een scherm om toegang te geven | Een access token voor hun API |

<!-- slide 12 -->

### Betalen en inloggen

| Betalen met Mollie | Log in met Google |
| --- | --- |
| Klant klikt op Betalen | Gebruiker klikt op Log in met Google |
| App maakt een payment aan bij Mollie | App bouwt een authorization url |
| Redirect naar de checkout | Redirect naar accounts.google.com |
| Klant betaalt bij Mollie | Gebruiker logt in bij Google |
| Terug met het payment id | Terug met een `code` |
| App haalt de status op bij Mollie | App ruilt de code in voor een token |

<!-- slide 13 -->

### Waarom komt een kaartnummer nooit op jouw server?

- A. Laravel blokkeert dat
- B. De klant typt het in op de pagina van Mollie
- C. Het wordt versleuteld voor het vertrekt
- D. Kaartnummers mogen niet in een database

**Antwoord:** B. De klant typt het in op de pagina van Mollie

Jouw app stuurt de klant weg naar de checkout. Wat daar ingevuld wordt, gaat rechtstreeks naar Mollie.

<!-- slide 14 -->

## 3. Providers

<!-- slide 15 -->

### De grote namen

|   | Van | Sterk in | Typisch voor |
| --- | --- | --- | --- |
| **Mollie** | NL | Eenvoudige API, Bancontact en iDEAL | Webshops en kmo’s in de Benelux |
| **Stripe** | VS | Developer experience, docs, abonnementen | SaaS en startups, wereldwijd |
| **PayPal** | VS | Vertrouwen, iedereen heeft een account | Marktplaatsen, internationaal |
| **Adyen** | NL | Enterprise, online en in de winkel | Uber, eBay, Spotify |

Je komt ook Klarna, Worldline, Buckaroo en Checkout.com tegen.

<!-- slide 16 -->

### Waarom Mollie

- Account en test key in vijf minuten, zonder contract
- Test mode is gratis en onbeperkt, zonder echt geld
- Bancontact, kaart, iDEAL en Apple Pay achter één API
- Een officieel Laravel-package
- Geen abonnement, je betaalt per transactie

**[docs.mollie.com](https://docs.mollie.com)**

**[laravel-mollie](https://github.com/mollie/laravel-mollie)**

<!-- slide 17 -->

## 4. Mollie in Laravel

<!-- slide 18 -->

### Setup

1. **Account**: mollie.com, Developers, API keys
2. **Package**: Installeren met Composer
3. **Key**: De test key in .env

- ```console
  $ composer require mollie/laravel-mollie
  ```
- *.env*

  ```ini
  MOLLIE_KEY=test_dHar4XY7LxsDOtmnkVtjNVWXLSlXsM
  ```

<!-- slide 19 -->

### De orders-tabel

```php
Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->decimal('total', 8, 2);
    $table->string('status')->default('open');
    $table->string('mollie_id')->nullable()->unique();
    $table->timestamps();
});
```

Het `mollie_id` is de brug tussen jouw order en de payment bij Mollie.

<!-- slide 20 -->

### Routes

```php
use App\Http\Controllers\PaymentController;

Route::post('/checkout', [PaymentController::class, 'pay'])
    ->name('payment.pay');

Route::get('/payment/return/{order}', [PaymentController::class, 'returned'])
    ->name('payment.return');
```

Eén route om de betaling te starten, één om de klant terug te ontvangen.

<!-- slide 21 -->

### Een payment aanmaken

```php
use Mollie\Api\Http\Data\Money;
use Mollie\Api\Http\Requests\CreatePaymentRequest;
use Mollie\Laravel\Facades\Mollie;

public function pay(): RedirectResponse
{
    $order = Order::create(['total' => '10.00']);

    $payment = Mollie::send(new CreatePaymentRequest(
        description: "Bestelling #{$order->id}",
        amount: Money::euro($order->total),
        redirectUrl: route('payment.return', $order),
        metadata: ['order_id' => $order->id],
    ));

    $order->update(['mollie_id' => $payment->id]);

    return redirect($payment->getCheckoutUrl(), 303);
}
```

<!-- slide 22 -->

### De klant komt terug

```php
use Mollie\Api\Http\Requests\GetPaymentRequest;

public function returned(Order $order): View
{
    $payment = Mollie::send(new GetPaymentRequest($order->mollie_id));

    if ($payment->isPaid()) {
        $order->update(['status' => 'paid']);
    }

    return view('orders.show', ['order' => $order]);
}
```

<!-- slide 23 -->

### Test mode

- Met een test key kies je op de checkout zelf de uitkomst: paid, failed, canceled of expired
- Geen echt geld, geen echte kaart
- Test en live zijn twee aparte werelden, met elk hun eigen payments en keys
- Live gaan is de key in `.env` vervangen door de `live_` key

<!-- slide 24 -->

### De klant betaalt, en sluit de tab voor Mollie hem terugstuurt. Wat weet jouw app?

- A. Dat hij betaald heeft
- B. Niets, de order staat nog op open
- C. Dat de betaling mislukt is
- D. Mollie stuurt een mail naar je app

**Antwoord:** B. Niets, de order staat nog op open

Jouw code draait pas als de browser terugkomt op de return url. Komt hij niet terug, dan weet je van niets. Het geld staat wel op je rekening.

<!-- slide 25 -->

### Het probleem

- **Browser crasht**: De betaling is gelukt, maar jouw app hoort het nooit.
- **Wifi valt weg**: Klant bevestigt in de Bancontact-app, de redirect terug komt nooit aan.
- **Tab te vroeg dicht**: "Mijn geld is weg, dus het is gelukt." En weg is hij.
- **Trage methodes**: Een overschrijving is pas na een paar dagen binnen.

<!-- slide 26 -->

## 5. Webhooks

<!-- slide 27 -->

### Polling en webhooks

#### Polling

Al betaald?

Jouw app vraagt het elke paar seconden aan Mollie. Veel calls, rate limits, en toch altijd te laat.

#### Webhook

Ik bel je wel.

Mollie stuurt een POST naar jouw app zodra een payment van status verandert. Eén route, meteen op de hoogte.

<!-- slide 28 -->

### Wat Mollie stuurt

```http
POST /payment/webhook HTTP/1.1
Host: jouwshop.be
Content-Type: application/x-www-form-urlencoded

id=tr_WDqYK6vllg
```

Alleen een id. Geen status, geen bedrag. Die haal je zelf op bij Mollie.

<!-- slide 29 -->

### De flow met een webhook

1. Laravel → Mollie, **Payment met webhookUrl**: Naast de `redirectUrl` nu ook een `webhookUrl`.
2. Mollie → Laravel, **Id en checkout url**
3. Laravel → Browser, **Redirect naar de checkout**
4. Browser → Mollie, **Klant betaalt**
5. Mollie → Laravel, **POST /payment/webhook**: Met in de body alleen `id=tr_WDqYK6vllg`.
6. Laravel → Mollie, **Status ophalen**: Met je eigen API key. Wat in de body stond, geloof je niet.
7. Laravel, **Order op paid**: Mail sturen, stock aanpassen, factuur maken. Ook als de klant zijn tab al dicht heeft.
8. Mollie → Browser, **Redirect naar je redirectUrl**
9. Browser → Laravel, **GET /payment/return**: Toont wat de webhook al in de database zette.

<!-- slide 30 -->

### Webhook url meegeven

- *config/services.php*

  ```php
  'mollie' => [
      'webhook_url' => env('MOLLIE_WEBHOOK_URL'),
  ],
  ```
- *.env*

  ```ini
  MOLLIE_WEBHOOK_URL=https://jouwshop.be/payment/webhook
  ```

*PaymentController@pay*

```php
$payment = Mollie::send(new CreatePaymentRequest(
    // description en amount zoals daarnet
    redirectUrl: route('payment.return', $order),
    webhookUrl: config('services.mollie.webhook_url'),
));
```

<!-- slide 31 -->

### De webhook

```php
Route::post('/payment/webhook', [PaymentController::class, 'webhook']);

public function webhook(Request $request): Response
{
    $payment = Mollie::send(new GetPaymentRequest($request->input('id')));
    $order = Order::where('mollie_id', $payment->id)->firstOrFail();

    if ($order->status === 'paid') {
        return response('ok');
    }

    $order->update(['status' => $payment->status]);

    if ($payment->isPaid()) {
        // mail sturen, stock aanpassen, factuur maken
    }

    return response('ok');
}
```

<!-- slide 32 -->

### 419 Page Expired

Laravel weigert elke POST zonder CSRF-token, en Mollie heeft er geen. In `bootstrap/app.php`:

```php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->preventRequestForgery(except: [
        'payment/webhook',
    ]);
})
```

<!-- slide 33 -->

### Regels voor een webhook

- Geloof de body niet. Haal de status zelf op.
- Antwoord met een 200, anders probeert Mollie het opnieuw
- Dezelfde call twee keer mag geen twee mails opleveren
- Hou hem kort. Mails en pdf’s gaan in een queued job.
- Terugbetalingen en chargebacks komen ook via de webhook binnen

<!-- slide 34 -->

### Je webhook geeft een 419 terug. Wat ben je vergeten?

- A. De route in de CSRF-uitzondering zetten
- B. De API key in .env
- C. Een 200 terugsturen
- D. php artisan migrate

**Antwoord:** A. De route in de CSRF-uitzondering zetten

419 is de CSRF-check van Laravel. Mollie stuurt geen token mee, dus de route moet in de except van preventRequestForgery.

<!-- slide 35 -->

## 6. Lokaal testen

<!-- slide 36 -->

### Localhost is alleen van jou

Mollie (ergens in de cloud) ✕ Jouw laptop (localhost:8000)

Mollie (ergens in de cloud) → Tunnel (a1b2.ngrok-free.app) → Jouw laptop (localhost:8000)

<!-- slide 37 -->

### ngrok en Expose

#### ngrok

```console
$ brew install ngrok
$ ngrok config add-authtoken <token>
$ ngrok http 8000
https://a1b2.ngrok-free.app -> localhost:8000
```

#### Expose

```console
$ composer global require exposedev/expose
$ expose token <token>
$ expose share http://localhost:8000
https://x7k2.sharedwithexpose.com
```

<!-- slide 38 -->

### De tunnel in .env

*.env*

```ini
MOLLIE_WEBHOOK_URL=https://a1b2.ngrok-free.app/payment/webhook
```

- Laravel draait gewoon verder op localhost:8000, jij surft daar ook naartoe
- Alleen Mollie gebruikt de publieke url
- Neem de vaste domeinnaam van je ngrok-account, anders verandert de url bij elke herstart

<!-- slide 39 -->

### Debuggen

- ngrok toont elke request op [127.0.0.1:4040](http://127.0.0.1:4040), en speelt hem opnieuw af met Replay
- `php artisan pail` toont je logs live in de terminal
- `Log::info('webhook', $request->all())` bovenaan de methode, zolang het niet werkt
- In het Mollie-dashboard zie je per payment de status en de metadata

<!-- slide 40 -->

### Opdracht: Een webshop die betaald wordt

*Individueel*

In een nieuw Laravel-project, met een test key van Mollie.

1. Een orders-tabel en een knop Betalen die een payment aanmaakt en naar de checkout stuurt
2. Een return-pagina die de status van de order toont
3. Een webhook via ngrok of Expose die de order op paid zet
4. Klaar als de order op paid komt, ook als je de tab sluit voor de redirect terug

## Begrippen

- **Redirect-principe**: Jouw app stuurt de klant naar een andere partij, daar gebeurt wat jouw app niet mag of kan, en de klant komt terug met een bewijs. Online betalen werkt zo, en Log in met Google ook. (slide 11)
- **Payment**: Een betaling zoals Mollie hem kent. Je maakt hem aan met een bedrag, een omschrijving en een redirectUrl, en je krijgt een id en een checkout url terug. (slide 21)
- **Payment id**: Het id dat Mollie aan een payment geeft, zoals tr_WDqYK6vllg. Je bewaart het als mollie_id bij je order: het is de brug tussen jouw order en de payment bij Mollie. (slide 19)
- **redirectUrl**: De url op jouw site waar Mollie de klant naartoe stuurt na de checkout, of hij nu betaald heeft of niet. Op die pagina weet je dus nog niet of er betaald is. (slide 9)
- **Payment status**: De stand van een payment: paid, open, failed, canceled of expired. Je haalt hem altijd zelf op bij Mollie met het payment id, nooit uit de url of de body van een request. (slide 9)
- **API key**: De sleutel waarmee jouw app zich aanmeldt bij Mollie, als MOLLIE_KEY in .env. Hij gaat alleen mee van server tot server en komt nooit in de browser. Een test key begint met test\_, een live key met live\_. (slide 18)
- **Test mode**: Mollie met een test key: geen echt geld, en op de checkout kies je zelf de uitkomst. Test en live hebben elk hun eigen payments en keys, live gaan is de key in .env vervangen. (slide 23)
- **Polling**: Je app vraagt om de paar seconden aan de provider of er al iets veranderd is. Veel calls, rate limits, en toch altijd te laat. (slide 27)
- **Webhook**: Een POST die Mollie naar jouw app stuurt zodra een payment van status verandert, ook als de klant zijn tab al dicht heeft. In de body zit alleen het id, de status haal je zelf op. (slide 27)
- **webhookUrl**: De url van je webhook, die je naast de redirectUrl meegeeft als je een payment aanmaakt. Het moet een publieke url zijn: een localhost-url kan Mollie niet bereiken. (slide 30)
- **CSRF-token**: Een token dat Laravel bij elke POST verwacht. Mollie stuurt er geen mee, dus zonder uitzondering in preventRequestForgery krijgt je webhook een 419 Page Expired. (slide 32)
- **Idempotent**: Twee keer hetzelfde doen geeft hetzelfde resultaat als één keer. Een webhook moet dat zijn: komt dezelfde call twee keer binnen, dan vertrekt er geen tweede mail. (slide 33)
- **Tunnel**: Je laptop zet een verbinding naar buiten op, en al het verkeer op een publieke url komt daarlangs binnen op localhost. Zo kan Mollie je webhook bereiken terwijl je lokaal werkt. (slide 36)
- **ngrok**: Een tunnel: ngrok http 8000 geeft je een publieke url naar localhost:8000. Neem de vaste domeinnaam van je account, anders verandert de url bij elke start. Op 127.0.0.1:4040 zie je elke request en speel je hem opnieuw af. (slide 37)
- **Expose**: Een tunnel in PHP, van Beyond Code. expose share http://localhost:8000 geeft je een publieke url. herd share in Herd is Expose onder de motorkap. (slide 37)
- **3D Secure**: De extra bevestiging van een kaartbetaling in de app van je bank. Ook een redirect: de klant gaat even weg en komt terug met een goedgekeurde betaling. (slide 11)

## Links en bronnen

### In de presentatie

- [Mollie docs](https://docs.mollie.com) (slide 16): Payments, webhooks en de statussen
- [laravel-mollie](https://github.com/mollie/laravel-mollie) (slide 16)

### Bronnen

- [Create a payment](https://github.com/mollie/mollie-api-php/blob/main/docs/recipes/payments/create-payment.md): CreatePaymentRequest en Money in mollie-api-php v3
- [Handle a payment webhook](https://github.com/mollie/mollie-api-php/blob/main/docs/recipes/payments/handle-webhook.md): Status ophalen, 200 terugsturen, refunds en chargebacks
- [CSRF protection in Laravel 13](https://laravel.com/docs/13.x/csrf): Een route uitsluiten met preventRequestForgery
- [ngrok](https://ngrok.com): Tunnel naar localhost
- [Expose installeren](https://expose.dev/docs/getting-started/installation): Tunnel in PHP, zit ook in Herd als herd share
- [OAuth 2.0](https://oauth.net/2/): Hetzelfde redirect-patroon, voor inloggen
