memberflow/plorea
Plorea is a Laravel package for building membership workflows: define plans, manage member access, and add subscription-ready features to your app with a clean, configurable API. It streamlines gated content and member management.
The package is a thin, facade-first SDK over the Plorea payments API — there is no admin UI, no migrations, and no routes published. Everything you touch is MemberFlow\Plorea\Facades\Plorea, which fans out into four resources: payments(), payByLink(), paymentMethods() and subscriptions().
Install and configure
composer require memberflow/plorea
Then set the minimum env in config/plorea.php (publish it if the package ships a config stub; otherwise the env vars are read directly):
PLOREA_API_KEY=plr_test_...
PLOREA_ENVIRONMENT=test # DEFAULTS TO test — see Gotchas
PLOREA_TENANT_ID=...
PLOREA_PLATFORM=my-app # no default; your own identifier
PLOREA_WEBHOOK_SECRET=...
PLOREA_WEBHOOK_VERIFY=true
PLOREA_RETRY_TIMES=0 # safe default for money writes
Where to look first
MemberFlow\Plorea\Data\Amount — the only place amounts are converted; Amount::nok(450000) is 4 500,00 kr. Read this before anything else, because every method takes minor units.agents/skills/plorea-payments, plorea-checkout and plorea-go-live markdown files. They are unusually good documentation for a 0-star package and are the authoritative source for the DTO shapes and the operational rules.->merchant(), ->payerEmail(), ->invoiceUrl()) are self-describing.First use case: a hosted payment link
Do not start with the embedded checkout. The hosted pay page at pay.plorea.no requires zero client-side code and zero Adyen configuration:
use MemberFlow\Plorea\Data\Amount;
use MemberFlow\Plorea\Facades\Plorea;
$link = Cache::lock("plorea:{$invoice->reference}", 10)->block(5, fn () =>
Plorea::payments()
->link(
reference: $invoice->reference,
product: $invoice->title,
amount: Amount::nok($invoice->total_minor),
returnUrl: route('invoices.show', $invoice),
)
->merchant(orgNr: $invoice->issuer_org_nr, name: $invoice->issuer_name)
->firstOrCreate()
);
return redirect()->away($link->url);
Persist $link->id and $link->expiresAt, and add the webhook endpoint in the same sitting — a link without a webhook means you are polling a payment state you could have been pushed.
Treat the DTOs as the contract. $link->url, $link->id, $link->expiresAt, $status->isPaid() — never reach for raw arrays. The session DTO in particular has a toCheckout() serializer that produces exactly {sessionId, sessionData, clientKey, environment}, which is the only safe thing to send to a browser. Returning response()->json($session) is the documented pattern; hand-rolling JSON from $session->raw leaks tenant and shopper identifiers.
Wrap money writes in a per-reference lock. firstOrCreate() is a check-then-create, not an atomic upsert. Any double-submit surface (a form, a queue retry, a user mashing the button) needs:
Cache::lock("plorea:{$reference}", 10)->block(5, fn () => $builder->firstOrCreate());
firstOrCreate() is still strongly preferred over create() because Plorea has no idempotency on duplicate references, and it supersedes dead or amount-changed links with a -1, -2 suffixed reference rather than throwing. Catch PaymentAlreadyPaidException and read $e->status->reference — it may name a suffixed reference, not the one you passed in.
Embedded checkout is a two-file pattern. The backend endpoint opens a session; the client mounts Adyen Drop-in. Keep them strictly separated:
// Backend — authorise the user against the invoice first, then:
$session = Plorea::payByLink()->session(
$link->id,
returnUrl: route('checkout.return'), // chosen by channel, on the server
channel: $channel,
);
return response()->json($session);
Pick returnUrl from the validated Channel enum (Web, iOS, Android), never from the request — that is an open redirect. New session per attempt; reuse the link, not the session.
Card storage and subscriptions. paymentMethods()->setup() with RecurringType::Subscription, ->merchant($orgNr, $companyName) (the name appears on the 3D Secure screen), then store $session->paymentMethodId. For recurring billing, subscriptions()->needingAttention() is the only way you will learn about a failed charge that Plorea dropped.
Webhooks: persist, then process, then re-fetch. Plorea does not redeliver. A 500 loses the event permanently, so persist the raw delivery and dispatch a queued listener that deduplicates on $event->eventId and calls Plorea::payments()->status($reference) rather than trusting the payload. Keep the webhook path out of CSRF, or every delivery is a 419.
Always ship a scheduled backstop. Card setup, cancel and reactivate emit no webhook at all; subscription.charge_failed is never retried. A Schedule::daily() job polling open payments, pending_setup payment methods past expiresAt, and needingAttention() subscriptions is not optional — it is the correctness mechanism for the states webhooks do not cover.
Test with the fake, assert on the wire, not the response.
Plorea::fake();
$this->postJson('/api/checkout', ['channel' => 'iOS'])->assertOk();
$this->assertJsonStructure(['sessionId', 'sessionData', 'clientKey', 'environment']);
Plorea::assertSent(fn ($r) => $r->input('channel') === 'iOS');
Book idempotently on your reference. Webhook, backstop poll and the return page all race. A unique index on reference plus firstOrCreate-style booking is the only pattern that survives.
PLOREA_ENVIRONMENT defaults to test. Since the fallback change, the API key itself sets the environment and a mismatched header returns 403 on every call. A live plr_live_ key with the variable unset is the single most common go-live failure, and it fails silently if you swallow PloreaException.
merchantOrgNr is mandatory and is never your own org number. It is the invoice issuer's — create() throws PloreaException without it because Plorea can route the link but cannot route the payout. The create response does not echo the merchant back, so if you need it later you must read Plorea::payByLink()->find($id)->merchantOrgNr. Subscriptions for a client company carry ->merchant() too, and KYC then starts on the first charge — there is no KYC API, so onboard with a payment link carrying the org number sent to yourself.
PLOREA_RETRY_TIMES=0 unless every write you issue is idempotent. Retried link creation or a retried charge double-bills. This is the one config value worth hard-coding per environment rather than trusting a default.
There is no capture API. Capture is automatic (AmendoPOS, minutes after authorisation) and authorised is terminal success. Do not build a pending-capture state machine or expose capture(); you will wait forever for a transition that does not exist.
Expiry is judged from expiresAt, not status. A superseded base reference stays created forever. Your "open link" query must be expiresAt > now(), or your dashboards will fill with zombie links.
No localhost origins. Every origin that mounts Drop-in must be whitelisted on the Adyen client key — including customer-owned domains and staging app identifiers. A missing origin fails late, as a CORS error inside /sessions/{id}/setup, which looks like a network bug rather than a config one. Develop on a whitelisted staging domain.
CSP is a hard requirement for embedded checkout. Adyen's checkoutshopper-* hosts need script/style/connect/frame permissions, plus issuer 3DS frames. Live hosts differ from test; copy the live list at go-live time, not from a blog post.
On the return page, do not create a new session. Re-create AdyenCheckout with session: { id } and countryCode (v6 requires it), call checkout.submitDetails({ details: { redirectResult } }), then strip redirectResult, sessionId and sessionResult with history.replaceState so a refresh cannot submit twice. In onError, keep the params and verify on the server.
onPaymentCompleted also fires for Pending and Received. Showing "paid" on any non-Authorised result is a live-money bug. And the server must never derive paid state from the browser — always from the webhook or a status() re-read.
Logging. If you have a global HTTP logger or a Laravel Telescope-style watcher, confirm it is not recording the Authorization header. Alert on ResponseReceived with status >= 400 and on ConnectionException; those two signals catch almost every integration regression.
Maturity caveat: 0 stars, 0 dependents, and a partially unverified surface. The docs are candid that an Android Drop-in on a device, native 3DS2 and a live environment value were still unobserved as of the last review, and that wallet support depends on the merchant's Adyen account and Apple domain verification. If any of those are on your roadmap, budget for a spike against the live key rather than trusting the README. Pin the version in composer.json, and consider wrapping the facade in your own PaymentGateway interface — the package's API is fluent and pleasant, but it is the only dependency in your stack with a single-maintainer risk profile, and a thin adapter makes a swap or a fork cheap.
Unverified extensions are the natural next step. Because the facade is thin and the response DTOs carry toCheckout(), adding a channel later (say a POS surface) means a new session endpoint plus a new Drop-in mount, not a rewrite. Just re-check the channel enum validation — channel is case-sensitive and returns 400 for anything outside Web, iOS, Android.
How can I help you explore Laravel packages today?