justlease/payum-ems
Payum gateway for EMS payments, helping Laravel/PHP applications initiate and handle EMS payment flows through Payum’s unified API. Adds the EMS integration layer so gateway-specific request and response handling stays out of your application code.
Install. justlease/payum-ems is Bitbucket-hosted and is not a verified Packagist package, so a plain composer require will not resolve. Use a VCS repository entry, and pin to a tag or commit — never dev-master in a payments codebase:
{
"repositories": [
{ "type": "vcs", "url": "https://bitbucket.org/justlease_nl/payumems.git" }
],
"require": {
"justlease/payum-ems": "dev-master#<commit-sha>",
"payum/payum": "^2.0"
}
}
The repo may be private: every developer machine and every CI runner needs credentials in COMPOSER_AUTH (http-basic or bitbucket-oauth) or ~/.composer/auth.json. Confirm the licence before you ship it commercially — a private Bitbucket repo with no LICENSE file is not a licence grant, and "we found it on Bitbucket" will not survive a finance or legal review.
Pick your Payum integration first. There is no first-party Laravel Payum bundle. You will either hand-wire Payum's PayumBuilder into a service provider (cleanest, fully under your control), or bolt on a third-party bundle — most of which are stale. Do not add Payum and a second payment abstraction (Stripe, Mollie, Cashier) side by side; choose one interface for payment work and route everything through it.
Read these files, in this order:
src/*GatewayFactory.php — the factory name string you pass to the builder, and the list of actions registered. This determines what is even possible (capture? refund? notify?).src/*Api.php — the real config contract: merchant ID, sub-ID, secret/API key, sandbox flag, endpoints, return/notify URLs. Guesswork here is wasted time; the Api constructor and doRequest-equivalent are the source of truth.src/Action/*.php — which Payum requests are supported and what they mutate on the model. This is where you learn whether Capture initiates the payment and captures a subsequent authorisation, and what it sets on the payment.First use case: a redirect flow with a capture token. Register the factory with your builder, configure a gateway with sandbox credentials, create a Payment, persist it, generate a CaptureToken, hand its target URL to the browser, and register the return URL as the token's afterUrl. Run that end-to-end in the EMS sandbox against a single fixed amount before touching any real domain logic. Getting one payment from "created" to "captured" and reconciled against the sandbox terminal is the whole learning curve; everything after that is repetition.
Wrap the gateway, not the controllers. Bind your own interface in a service provider and let the Payum types stay behind it:
interface EmsGateway
{
public function start(Order $order): string; // returns redirect URL
public function capture(string $token): PaymentResult;
public function sync(Payment $payment): PaymentResult;
}
Callers get PaymentResult (status enum, provider reference, amount, failure reason) and never see Payum\Core\Request\Capture or TokenInterface. This is what makes the eventual exit to Adyen/Mollie a one-class change rather than a grep across the app — and given this package's adoption profile, that exit path is not hypothetical.
Model the payment explicitly. Extend Payum\Core\Model\Payment (or implement PaymentInterface) and back it with Eloquent. Store the gateway name, the resolved gateway ID, your order ID, currency, integer minor-unit amount, provider reference, status, and the raw response JSON. Payum's model carries some of this, but finance needs your own columns for reconciliation, and the raw response is what you will be reading at 2am.
Two routes, two responsibilities. A return route (GET, where the customer lands) and a notify route (POST, EMS server-to-server). They are not interchangeable: the return URL is a UX event, the notification is the state transition. Both must be absolute, HTTPS, and publicly reachable — url()->route() under APP_URL=http://localhost is the single most common reason the sandbox "works" but nothing completes.
Handle the notification off-request. Exclude the notify URI from CSRF in VerifyCsrfToken::$except, validate nothing but that the body parses, dispatch a queued job, and return 200 immediately. Then in the job: re-fetch the payment by the reference in the payload, and call back to EMS for the authoritative status rather than trusting the payload. Only then transition state and emit your domain event. Processing inline in the webhook request couples your reconciliation to EMS's retry schedule and exposes you to forged "succeeded" callbacks.
Treat statuses as three outcomes, not two. Success, decline, and unknown. A timeout is not a failure — it is an orphaned authorisation waiting for reconciliation. Keep the payment in pending/unknown and let a scheduled sync job resolve it. Conflating these is how customers get charged twice.
Sequence the first release. Sandbox run → wrap behind your interface → staging against the EMS test terminal with failure and timeout cases → canary behind a per-tenant feature flag → keep the existing payment path live with dual-written records until reconciliation survives a full settlement cycle.
Payum's Capture is context-sensitive. The same request object both initiates a payment (when the model has no authorisation) and captures an authorisation (when it does). If your code branches on "is this a new payment or a capture", you will eventually get it backwards. Let the gateway's actions decide and read the result, don't pre-judge.
Results come back off the request, not as return values. $gateway->execute($request) may throw HttpResponse (a redirect), RequestNotSupportedException, or a transport exception, and otherwise leaves the model on $request->getModel() / $request->getFirstModel(). Write one small helper that normalises all three into your PaymentResult and never let those exceptions escape into a controller.
Amounts are integers in minor units. €49.95 is 4995. Convert once, at the boundary, from a value object — never float-multiply inside a request class. Multi-currency and partial refunds are where rounding bugs appear, and they appear in finance's reconciliation, not in your logs.
Capture tokens are single-use and time-limited. Regenerating one on a page refresh is fine; reusing one after a completed capture throws. Persist tokens with their payment and expire them on your side too.
Currency and amount must match exactly between initiation and capture. EMS will reject a mismatch with an error that reads like a generic decline.
Pin the matrix, not just the package. Payum 1.x and 2.x differ substantially, and older releases break on PHP 8.2/8.3 via dynamic-property deprecations. Test the resolved dependency tree — composer show payum/payum symfony/http-foundation — not the declared constraints, and record the PHP/Laravel/Payum/Symfony versions in the PR.
Check what is actually implemented before promising it. Grep src/Action/ for Refund, Notify, Sync, Status. A missing refund action is not a bug you can configure around; it becomes manual work for finance on day one. Same for 3DS/SCA — if there is no visible handling, assume the hosted flow carries it and get that confirmed, because PSD2 is not optional for Dutch card payments.
Never trust the notify payload. Anyone can POST to a public URL. Always verify by calling EMS for status, and log the inbound payload alongside your outbound verification request. Also log request/response bodies with amounts and provider references, but redact card data — even on a hosted flow, error payloads occasionally echo more than you expect.
Configuration belongs in config/services.php plus .env. Sandbox flag, merchant ID, sub-ID, secret, return/notify URLs. Because these are read when the Api is constructed, a cached config with a stale sandbox flag is a genuinely dangerous failure mode — assert the environment at boot in anything but production.
CI credentials are a real failure mode. An expired Bitbucket app password takes down deploys for the whole team with a confusing Composer error. Rotate it deliberately, and mirror the repo to your own Git host as soon as you fork — which you will, because you own this code the moment you ship it.
How can I help you explore Laravel packages today?