PHP in Fintech

Modern PHP 8 with Laravel or Symfony runs more of the payment stack than most people assume. Where it fits in fintech, where it does not, and the patterns that keep money correct.

· · Updated · 28 min read

Illustration of the PHP elephant beside payment card, webhook and ledger symbols
In this article

Why PHP matters in fintech

PHP has a reputation problem and a market share problem, and they point in opposite directions. The reputation is stuck somewhere around 2008: a templating language that grew functions by accident, where mysql_query and string concatenation were the normal way to talk to a database. The market share tells a different story. PHP still runs a large majority of the websites whose server-side language can be detected, it powers the checkout of the largest open-source e-commerce platforms, and almost every payment service provider publishes a PHP SDK early.

For fintech, the second fact matters more than the first. Money moves through merchant integrations, and a disproportionate share of those integrations are written in PHP. Whether you build a gateway, a BNPL product, a wallet or a lending platform, the code on the other side of your API is very likely PHP. Understanding the language is how you understand your customers.

The language is not what it was

PHP 8.x is a different language from the one most critics remember. Since PHP 7.0 the engine has been rebuilt for speed, and since 8.0 the type system has become serious: union and intersection types, readonly properties and classes, enums, named arguments, match expressions, first-class callable syntax, fibers, and declare(strict_types=1) as the first line of every file in a well-run codebase. PHP 8.3 added typed class constants and json_validate(); PHP 8.4 added property hooks and asymmetric visibility. With PHPStan or Psalm in CI, you can write a codebase where Money refuses to add dirhams to riyals and a payment status is an enum rather than a magic string. That is the baseline fintech needs, and PHP now provides it.

Why this article exists

I write this for founders, CTOs and senior engineers in Egypt, Saudi Arabia and the UAE who are either inheriting a PHP codebase or deciding whether to start one. The honest answer to "should fintech be built in PHP?" is "it depends on which part of fintech", and this piece tries to make that answer precise: where PHP is a strong default, where it is a liability, and the patterns that separate PHP applications that handle money well from the ones that generate audit findings.

PHP's biggest fintech advantage is not the language. It is the fact that the merchant on the other end of your API is probably running it too.

Where it is used

This section sticks to publicly documented usage; I would rather name fewer systems and be sure about each one.

Payment service providers and their SDKs

Stripe maintains an official PHP library, stripe-php, one of its longest-lived SDKs. Adyen, Mollie and PayPal publish official PHP clients. In the MENA market, Paymob, Fawry, Tap, Moyasar and PayTabs all publish PHP SDKs or PHP integration examples in their developer documentation.

Billing and subscription layers

Laravel ships Cashier, a first-party package that wraps Stripe and Paddle subscriptions, invoices, trials, coupons and webhook handling in an idiomatic Laravel API. Symfony's ecosystem has equivalent bundles, and both communities maintain packages for invoicing, tax calculation and dunning.

Commerce checkouts

WooCommerce, Magento (now Adobe Commerce) and PrestaShop are PHP applications, and their payment-gateway plugin ecosystems are PHP. Shopware and Sylius are Symfony-based commerce platforms. Each has a checkout that hands off to a PSP, receives a webhook and updates an order state, and that plumbing is PHP. If your product needs to be inside the checkout of a MENA e-commerce merchant, you will be writing or auditing a PHP plugin.

Large PHP systems outside fintech

Because "can PHP handle load?" comes up in every architecture review, it is worth remembering what it has run. Wikipedia runs on MediaWiki, a PHP application. Slack's backend was built in PHP and later moved to Hack on HHVM, as Slack has described in its engineering blog. Mautic, the open-source marketing automation platform, is Symfony-based. None of these are banks, but all of them show that the runtime model scales when the engineering around it is competent. In MENA fintech specifically, merchant-facing layers, back-office portals, PSP integrations and KYC orchestration are frequently Laravel, while newer ledger or risk cores are more often Go, Java, Python or Kotlin; the rest of this article explains why.

Strengths

Request isolation is a feature, not a limitation

PHP's execution model is shared-nothing: each request gets a fresh process state, runs, and throws everything away. Engineers used to long-running runtimes call that waste; in a payments system I call it a safety property. A memory leak in one request does not poison the next. A fatal error takes down one request, not the worker pool. There is no stale cache in process memory and no accidental mutable singleton holding yesterday's exchange rate.

The type system is now good enough for money

With declare(strict_types=1), scalar types are enforced rather than coerced. With readonly classes, value objects are immutable by construction. With enums, a payment status cannot be 'Settled' in one file and 'settled' in another. With PHPStan at a high level in CI, nullability errors are caught before code review. None of this is exotic; it is the standard toolkit of a PHP 8 team.

Two mature frameworks with different philosophies

Aspect Laravel Symfony Micro-framework or plain PHP
Philosophy Batteries included, convention first Components, explicit configuration Minimal, you assemble it
Learning curve for a MENA team Low; very large local community Medium; strongest in Europe Depends entirely on the team
Payments-adjacent tooling Cashier (Stripe, Paddle), Horizon, Octane Messenger, Workflow, Security component Whatever you wire in
Long-term structure Needs discipline to avoid fat models Encourages layered design Encourages nothing
Best for Product teams moving fast Platforms with many bounded contexts Edge services, plugins, SDK code

Laravel is the default for product teams in the region because the hiring pool is deep and the first-party packages cover queues, scheduling, notifications, rate limiting, authentication, encryption and billing on day one. Symfony is the better fit when the system is large, multi-team and expected to live for a decade; its components are the foundation of most of the PHP ecosystem, including Laravel itself.

The ecosystem around payments is unusually deep

Composer and Packagist give you vetted libraries for the hard parts: brick/money and moneyphp/money for currency arithmetic, ramsey/uuid for identifiers, Monolog for structured logging, and a long list of PSP clients. The PSR standards for HTTP messages, middleware, logging and autoloading mean they compose across frameworks. Operationally, PHP-FPM behind Nginx is one of the best understood production setups in the industry, and for a small team that also has to pass a regulator's inspection, boring is a feature.

Risks and pitfalls

Floating-point money

The single most common money bug in PHP codebases is float. 0.1 + 0.2 is not 0.3 in PHP, as in every IEEE 754 language, and PHP makes it worse by silently converting numeric strings to floats in arithmetic. If your amounts are floats, you will eventually have a ledger that does not balance and no way to prove which entry is wrong. The fix is non-negotiable: store and compute money as integers in the currency's minor unit, carry the currency alongside the amount, and let a value object enforce the rules.

A minimal Money value object

The libraries above are the right choice for production; the snippet below shows how little PHP 8.3 needs to express the rules. Everything is readonly, the currency is an enum, and allocation distributes leftover minor units instead of losing them.

<?php
declare(strict_types=1);

enum Currency: string
{
    case EGP = 'EGP';
    case SAR = 'SAR';
    case AED = 'AED';
}

final class CurrencyMismatch extends LogicException {}

/** Amounts are integers in minor units (piastres, halalas, fils). Never floats. */
final readonly class Money
{
    public function __construct(public int $minor, public Currency $currency) {}

    public function add(Money $other): self
    {
        $this->assertSameCurrency($other);
        return new self($this->minor + $other->minor, $this->currency);
    }

    /** Split by ratios; leftover minor units go to the first parts, so nothing is lost. */
    public function allocate(int ...$ratios): array
    {
        $total = array_sum($ratios);
        if ($total <= 0 || min($ratios) < 0 || $this->minor < 0) {
            throw new InvalidArgumentException('allocate() needs a non-negative amount and ratios');
        }
        $parts = [];
        $remainder = $this->minor;
        foreach ($ratios as $ratio) {
            $share = intdiv($this->minor * $ratio, $total);
            $parts[] = $share;
            $remainder -= $share;
        }
        for ($i = 0; $remainder > 0; $i++, $remainder--) {
            $parts[$i]++;
        }
        return array_map(fn (int $p) => new self($p, $this->currency), $parts);
    }

    private function assertSameCurrency(Money $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new CurrencyMismatch("{$this->currency->value} vs {$other->currency->value}");
        }
    }
}

// 100.01 EGP split three ways: [3334, 3334, 3333] piastres, which still sums to 10001.
$parts = (new Money(10001, Currency::EGP))->allocate(1, 1, 1);

Tested with PHP 8.3.

A real system also needs subtraction, comparison, multiplication with an explicit rounding mode and the per-currency exponent, which is exactly what brick/money and moneyphp provide. Use them; write your own only to learn the lesson.

Loose comparison and type juggling

PHP 8 fixed the worst of the string-to-number comparison rules, but == is still a trap: "1e3" == "1000" is true. Use === for everything and hash_equals() for anything secret, and configure your static analyser to flag loose comparison.

Framework magic hiding money logic

Laravel's Eloquent is a joy for CRUD and a hazard for ledgers. Mass assignment, implicit casts, observers that fire on every save, and accessor methods that do arithmetic are all places where a monetary rule can hide. The rule in a fintech codebase: money logic lives in explicit, typed service classes and value objects; ORM models are for persistence and nothing else.

The legacy-code trap

Most PHP fintech work in MENA is not greenfield. It is a gateway that has run since PHP 5.6, a merchant portal on an abandoned framework, or a WordPress plugin that grew into a product. These systems work, which is why they still exist, and they are also where every classic vulnerability lives: unparameterised SQL, extract($_POST), MD5 password hashes, secrets in the repository. Upgrading them is engineering work, not a composer update. Rector automates a surprising amount of the syntax migration; it cannot automate the architecture. A related trap is the cron-driven state machine, where overlapping runs double-process a payment and a crashed run leaves one stuck; state transitions belong on a queue with explicit retry policies.

Architecture and integration patterns

The PSP integration layer

Every fintech PHP application that touches a payment provider has the same three surfaces: an outbound client that creates intents, captures, refunds and payouts; an inbound webhook endpoint that receives asynchronous state changes; and a reconciliation process that compares the provider's view with yours. The mistake I see most often is letting those three surfaces share mutable state through ORM models. The pattern that works is an anti-corruption layer: one interface per provider capability, provider-specific adapters behind it, and a provider-agnostic domain model in the middle, so that switching gateways becomes configuration instead of a rewrite.

Verifying webhook signatures

Webhooks are where a payment system learns that money moved, which makes them the most attacked endpoint in the application. Three rules: verify the signature over the raw body, reject anything outside a timestamp window to stop replays, and compare in constant time. The class below is framework-agnostic and uses only the standard library.

<?php
declare(strict_types=1);

final class WebhookVerifier
{
    public function __construct(
        private readonly string $secret,
        private readonly int $toleranceSeconds = 300,
    ) {
        if ($secret === '') {
            throw new InvalidArgumentException('Webhook secret must not be empty');
        }
    }

    /**
     * $rawBody is the untouched request body (php://input), never re-encoded JSON:
     * one changed byte breaks the HMAC. Header format: "t=<unix>,v1=<hex>".
     */
    public function verify(string $rawBody, string $signatureHeader, ?int $now = null): bool
    {
        $now ??= time();
        $parts = [];
        foreach (explode(',', $signatureHeader) as $pair) {
            [$k, $v] = array_pad(explode('=', trim($pair), 2), 2, '');
            $parts[$k] = $v;
        }
        $timestamp = (int) ($parts['t'] ?? 0);
        $received = strtolower($parts['v1'] ?? '');
        if ($timestamp <= 0 || $received === '') {
            return false;
        }
        // Replay protection: a valid signature outside the window is still rejected.
        if (abs($now - $timestamp) > $this->toleranceSeconds) {
            return false;
        }
        // The timestamp is signed together with the body, so it cannot be swapped.
        $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $this->secret);
        // Constant-time comparison; never compare signatures with === or ==.
        return hash_equals($expected, $received);
    }
}

// Usage in any framework: feed the raw body, not the parsed array.
$verifier = new WebhookVerifier((string) getenv('PSP_WEBHOOK_SECRET'));
$rawBody = (string) file_get_contents('php://input');
$header = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
if (!$verifier->verify($rawBody, $header)) {
    http_response_code(400);
    exit;
}
$event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
// Deduplicate on $event['id'] before doing any work; providers retry deliveries.

Tested with PHP 8.3.

Signature formats vary by provider; adapt the parsing but keep hash_equals(), the timestamp window and the raw-body rule. After verification, deduplicate by the provider's event id, because every serious provider retries deliveries, and respond with a 2xx quickly after enqueueing the real processing.

Idempotent payment endpoints

Clients retry. Mobile networks drop requests mid-flight, load balancers time out, and the merchant's PHP plugin will happily resend the same POST /payments three times. An idempotency key turns a retry into a replay of the first response instead of a second charge. The handler below claims the key and inserts the payment in one transaction, stores the response alongside the key, and replays it on duplicates.

<?php
declare(strict_types=1);

/** Tables: idempotency_keys(idem_key PK, request_hash, http_status NULL, body NULL, created_at)
 *  and payments(id, amount_minor, currency, merchant_ref, status, created_at). */
final class IdempotentPaymentHandler
{
    public function __construct(private readonly PDO $db) {}

    /** Returns [httpStatus, jsonBody]. SQL is PostgreSQL flavoured (ON CONFLICT, RETURNING). */
    public function handle(string $key, array $payload): array
    {
        $hash = hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR));
        $this->db->beginTransaction();
        try {
            // Claim the key first: a duplicate means a retry, not a new payment.
            $claim = $this->db->prepare('INSERT INTO idempotency_keys (idem_key, request_hash, created_at)
                VALUES (:k, :h, NOW()) ON CONFLICT (idem_key) DO NOTHING');
            $claim->execute([':k' => $key, ':h' => $hash]);
            if ($claim->rowCount() === 0) {
                $this->db->rollBack();
                return $this->replay($key, $hash);
            }
            $insert = $this->db->prepare('INSERT INTO payments (amount_minor, currency, merchant_ref, status, created_at)
                VALUES (:a, :c, :r, :s, NOW()) RETURNING id');
            $insert->execute([':a' => $payload['amount_minor'], ':c' => $payload['currency'],
                ':r' => $payload['merchant_ref'], ':s' => 'pending']);
            $body = json_encode(['id' => $insert->fetchColumn(), 'status' => 'pending'], JSON_THROW_ON_ERROR);
            $this->db->prepare('UPDATE idempotency_keys SET http_status = 201, body = :b WHERE idem_key = :k')
                ->execute([':b' => $body, ':k' => $key]);
            $this->db->commit(); // key, payment and stored response land together
            return [201, $body];
        } catch (Throwable $e) {
            if ($this->db->inTransaction()) {
                $this->db->rollBack();
            }
            throw $e;
        }
    }

    private function replay(string $key, string $hash): array
    {
        $stmt = $this->db->prepare('SELECT request_hash, http_status, body FROM idempotency_keys WHERE idem_key = :k');
        $stmt->execute([':k' => $key]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        if ($row === false || $row['request_hash'] !== $hash) {
            return [422, '{"error":"idempotency key reused with a different payload"}'];
        }
        if ($row['http_status'] === null) {
            return [409, '{"error":"original request still in progress, retry later"}'];
        }
        return [(int) $row['http_status'], (string) $row['body']];
    }
}

Tested with PHP 8.3.

Two details matter more than they look. Reusing a key with a different payload must be rejected, not silently replayed, so hash a canonical form of the payload (validated fields, sorted keys). And in PostgreSQL a concurrent duplicate blocks on the claimed key until the first transaction finishes, so by the time replay() runs the stored response is already there; the "in progress" branch is a fallback for databases with different locking semantics. Scope keys per merchant and expire them after a day or so.

Queues as the backbone

Nothing that talks to a third party happens inside an HTTP request. PSP calls, KYC calls, SMS, email and ledger fan-out all go onto a queue. In Laravel that is the queue system with Redis and Horizon for visibility; in Symfony it is Messenger with AMQP or Redis transports. The HTTP request's job is to validate, persist an intent, enqueue, and return 202 Accepted with an id the client can poll.

The ledger belongs in the database

Double-entry ledgers in PHP are perfectly feasible; the discipline is in the database, not the language. Append-only journal entries, balances derived from entries rather than stored and updated, constraints that reject an unbalanced transaction, and SELECT ... FOR UPDATE or serialisable isolation around any operation that reads a balance and then writes. PHP's role is to express the domain clearly and never to bypass those constraints with a convenient update() call.

Modular monolith before microservices

For a fintech team of fewer than thirty engineers, a modular monolith in Laravel or Symfony beats microservices on almost every axis: one deployment to audit, one database to back up, one transaction boundary around a payment and its ledger entries. Split out services when a component genuinely needs a different runtime (a Go matching engine, a Python risk model) or a different compliance boundary (card data handling).

Every service boundary in a payments system is a place where a distributed transaction can half-complete. Add them only when the alternative is worse.

Security and compliance

What the regulators actually want from your code

Regulatory frameworks in the region differ in detail but converge on the same engineering demands. PCI DSS governs anything that touches card data. SAMA in Saudi Arabia, CBUAE in the UAE and CBE in Egypt each publish cybersecurity, outsourcing and licensing frameworks, and PSD2-style open-banking regimes matter if you plug into open-banking APIs. None of these frameworks care that you use PHP. All of them care about access control, encryption, secrets, logging, change management, vulnerability management and where data lives.

Keep card data out of your scope

The most effective PCI decision is architectural: never let a primary account number reach your servers. Hosted payment fields, redirect checkouts or tokenisation from the PSP mean the card number goes from the customer's browser or app straight to the provider, and your PHP backend only ever sees a token. That typically keeps you in the lightest self-assessment category.

The cheapest PCI control is the card number you never receive.

Secrets management

.env files are a development convenience, not a secrets store. In production, secrets come from the platform (a vault or a cloud secrets manager), are rotated on a schedule, and are never committed. Symfony ships an encrypted secrets vault for exactly this. PHP 8.2 added the #[\SensitiveParameter] attribute, which redacts a parameter's value from stack traces; use it on every function that accepts a key, token or password, because stack traces in log files are a common way secrets leak.

Composer and the supply chain

Commit composer.lock. Install with composer install from the lock file in CI and production, never composer update. Run composer audit in the pipeline against known advisories and fail the build on findings. Pin the PHP version with config.platform.php, and review any package with install scripts before adding it. A Laravel application with a hundred and fifty packages is normal; every one of them is code you are responsible for in front of an auditor.

PII handling and audit logs

Fintech applications hold national ids, phone numbers, addresses, salary data and sometimes biometrics. Encrypt sensitive fields at rest at the application level (Laravel's encrypted cast or Symfony's equivalent), so a database dump without the key is useless. Mask PII in logs with a Monolog processor rather than hoping developers remember. Define retention periods and actually delete, and minimise what you collect: KYC data you never needed is data you have to protect for years under the personal data protection laws that Egypt, Saudi Arabia and the UAE now all have.

Regulators and fraud investigators will also ask "who changed this and when". An audit log is an append-only table with actor, action, subject, before and after values, timestamp and request id, written in the same transaction as the change it describes. Framework model events alone are not enough, because raw queries and bulk updates skip them. Keep audit logs in a store that application code cannot update or delete.

Sessions, CSRF and authentication

Both major frameworks handle CSRF tokens, session regeneration on login and secure cookie flags by default; the risk is in what teams turn off. Keep SameSite, Secure and HttpOnly on session cookies, regenerate the session id on every privilege change, and set short lifetimes for anything that can move money. Password hashing is password_hash() with bcrypt or Argon2id, nothing else, and authentication endpoints are rate-limited aggressively.

Data residency and hardening

SAMA expects certain categories of data for Saudi financial institutions to be hosted inside the Kingdom; the UAE has sector-specific localisation expectations; Egypt's data protection regime has cross-border transfer rules. Your queue, cache, object storage, logging pipeline and error-tracking SaaS all count as places data lives; shipping exceptions with request payloads to a bug tracker hosted abroad can breach residency rules without anyone noticing. On the platform side, run PHP-FPM as an unprivileged user, keep display_errors off in production, and keep PHP within its support window; an end-of-life PHP in a payment system is an audit finding waiting to be written.

Performance and scaling

How PHP-FPM actually scales

PHP-FPM runs a pool of worker processes; each worker handles one request at a time, start to finish. Throughput is therefore workers divided by average request time. A request that spends most of its time waiting on a PSP's API holds a worker hostage for the whole wait, which is the single biggest reason to push third-party calls onto queues. Tune pm.max_children against available memory and keep the request path short; horizontal scaling is then just more identical servers behind a load balancer.

OPcache and the JIT

OPcache is not optional in production; it caches compiled bytecode so PHP does not re-parse your application on every request. Set opcache.validate_timestamps=0 and reset the cache on deploy. The JIT, available since PHP 8.0, helps CPU-bound code such as numeric loops and does very little for the typical I/O-bound fintech request; measure before you enable it.

Long-running runtimes

The PHP-FPM bootstrap cost (loading the framework, building the container) is paid on every request. Long-running runtimes keep the application booted in memory and serve requests from workers that persist.

Runtime Model What you gain What you must watch
PHP-FPM One process per request Simplicity, isolation, universal ops knowledge Per-request bootstrap cost
FrankenPHP Go (Caddy) server embedding PHP, worker mode Simple deployment, modern HTTP, worker mode without extra extensions Newer; fewer ops veterans
RoadRunner Go application server with PHP workers Mature, works with Octane and Symfony A separate binary to operate
Swoole / Open Swoole Coroutine event loop inside a PHP extension Highest throughput, native async Extension install, coroutine-unsafe libraries, state leaks

Laravel Octane runs Laravel on FrankenPHP, RoadRunner or Swoole; Symfony runs on FrankenPHP and RoadRunner through its runtime component. The latency gain is real; the price is that your code now has state that survives between requests: static properties, singletons that captured a user, memoised configuration containing a tenant id all become bugs. For a payments API this is a correctness risk, and I would adopt a long-running runtime only with a test suite that runs under it.

Queues, workers and connections

Whether you run Horizon or Messenger, the rules for money are the same: make every job idempotent, because at-least-once delivery means it will run twice eventually; set explicit retry limits and backoff; route payment-critical jobs to their own queue so a flood of email jobs cannot delay a settlement; and monitor queue depth as a first-class production metric.

Each PHP-FPM worker opens its own database connection, so a fleet of servers can exhaust a PostgreSQL connection limit with ease. Put PgBouncer or ProxySQL in front of the database, use persistent connections cautiously, and keep transactions short. Read replicas help reporting but must never serve a balance read that precedes a write, and Redis caches reference data such as currencies and fee tables, never a balance.

Where PHP is a poor fit

Be honest about the edges. PHP is a weak choice for long-lived connections (WebSocket servers, streaming market data), for heavy numeric work (risk models, reconciliations over millions of rows in memory), and for anything that needs a genuinely concurrent in-process scheduler. Swoole can do some of these, but you will be fighting the ecosystem. The right move is a separate service in Go, Rust, Python or the JVM, with PHP orchestrating through queues and APIs.

Team and hiring in MENA

The talent pool

Egypt has one of the deepest PHP talent pools in the region, built over two decades of agencies and product companies on WordPress, CodeIgniter, Yii and, lately, Laravel. Saudi Arabia and the UAE hire much of their PHP capacity from Egypt, Jordan, Pakistan and India. You can staff a Laravel team in Cairo faster than almost any other backend stack, and a Riyadh or Dubai fintech can source senior PHP engineers remotely across the same time zones.

Depth is uneven

Breadth is not depth. A large share of the pool learned PHP as "Laravel plus Eloquent plus Blade" and has never written a value object, configured PHPStan or thought about float precision. Another share learned it in 2010 and never fully left; they know the language intimately but reach for global state and raw SQL. Neither profile is useless for fintech; both need a clear engineering standard and a code review culture that enforces it.

Interview signals that matter

The questions I find most predictive for PHP fintech roles:

  • Ask them to add two amounts in different currencies. Do they ask what the amounts are stored as?
  • Ask what happens when a client retries a POST /payments request. The word "idempotency" should appear without prompting.
  • Ask how they would verify a webhook. Listen for raw body, HMAC, constant-time comparison and replay windows.
  • Ask what declare(strict_types=1) changes and what PHPStan level they run.
  • Ask when they would not use the ORM. The answer tells you whether they understand ledgers.

Upskilling an existing team

The fastest path I know from "we have a PHP 7 team" to "we have a PHP 8 fintech team": turn on strict_types in new files, add PHPStan and ratchet its level up monthly, adopt Rector for mechanical upgrades, introduce value objects for money and identifiers, move third-party calls to queues, put Pest or PHPUnit tests around the money paths, and write a short engineering standard that reviewers can point to. None of this requires a rewrite; all of it compounds. Most hires will spend their first months in legacy code, so make the modernisation plan visible: teams leave when they believe the legacy is forever.

Decision framework

When PHP with Laravel or Symfony is the right call

Choose PHP when the system is primarily transactional web and API work: merchant portals, back-offices, PSP integrations, KYC orchestration, billing, notifications, partner APIs and e-commerce plugins. Choose it when you need to hire quickly in Egypt, Saudi Arabia or the UAE, when the team already knows it, or when your product has to live inside someone else's PHP ecosystem. Choose Laravel for a product team that must ship in months; choose Symfony for a platform expected to outlive the founding team.

When to choose something else

Choose a different language for a ledger core that needs very low latency or extreme throughput, for market-data or matching engines, for risk and fraud models, for long-lived connections, or where a smaller separate codebase makes a compliance boundary cleaner. Choose something else if your senior engineers are genuinely stronger elsewhere; a great Go team beats an average Laravel team every time.

Decision matrix

Component PHP (Laravel / Symfony) Python Go / JVM / Rust Notes
Merchant and admin portals Strong Adequate Overkill The hiring pool decides
PSP integrations and webhooks Strong Strong Strong Match the SDK and the team
Subscription billing Strong (Cashier) Adequate Adequate Laravel's first-party tooling
KYC and onboarding orchestration Strong Strong Adequate Mostly I/O and state
Double-entry ledger at moderate scale Adequate Adequate Strong PostgreSQL does the heavy lifting
High-throughput ledger or matching Weak Weak Strong Latency and concurrency
Risk, fraud and scoring models Weak Strong Adequate Ecosystem, not language speed
Real-time streams and WebSockets Weak Adequate Strong Long-lived connections
E-commerce plugins and merchant SDKs Strong Weak Weak The merchant runs PHP
Batch reconciliation at scale Adequate Strong Strong Memory and numeric work

A reasonable default for a MENA fintech

If I had to pick a default for a payments or wallet startup in the region today: Laravel for everything merchant- and operator-facing and for PSP orchestration, PostgreSQL as the system of record with the ledger rules enforced in the schema, Redis queues with Horizon, hosted PSP fields to keep PCI scope minimal, and a clear plan to extract a separate service the day the ledger or risk engine needs it. That stack is boring, auditable and staffable, which is exactly what a regulated product needs.

FAQ

Is PHP secure enough for a regulated financial product?

Yes, with the qualification that applies to every language: security depends on the engineering around the runtime. PHP 8 with strict types, a supported version, framework defaults left on, prepared statements everywhere, secrets outside the repository and a Composer audit in CI is as defensible in front of SAMA, CBUAE or CBE as any other stack. Most PHP audit findings come from legacy code and disabled defaults, not from the language.

Laravel or Symfony for a payments platform?

Laravel if you are a product team that needs to ship and hire quickly in the region. Symfony if you are building a large, multi-team platform with explicit boundaries that must last a decade. Both use the same money libraries and pass the same audits; the wrong choice is to pick one and then fight its conventions.

Should I rewrite my legacy PHP 7 fintech codebase?

Rarely. Upgrade in place: move to a supported PHP version with Rector's help, add static analysis and tests around the money paths, extract value objects and queues, and strangle the worst modules one at a time. Full rewrites of working payment systems take longer than anyone estimates and run two systems in parallel during the most dangerous period.

Can PHP handle real-time features like live balances and notifications?

Partly. Notifications triggered from queue workers are routine. Holding thousands of WebSocket connections in PHP is possible with Swoole, but a dedicated service or a managed push provider is usually the better choice, with PHP publishing events to it. The balance itself stays in the database; the real-time layer carries notifications, never the source of truth.

How do I keep PCI DSS scope small in a PHP application?

Never receive a card number. Use the PSP's hosted fields, redirect checkout or mobile SDK so card data goes directly to the provider and your backend only stores tokens. Then make sure nothing in your logs, caches, backups or error trackers can see card data either. What remains is your handling of tokens and your general security posture, a far smaller audit than a full card-data environment.

Key takeaways

  • PHP matters in fintech because the merchant side of the industry runs on it: PSP SDKs, commerce checkouts and billing layers are overwhelmingly PHP.
  • PHP 8.x with strict_types, readonly value objects, enums and PHPStan gives you the correctness tooling that money requires; the 2008 reputation is out of date.
  • Store and compute money as integers in minor units with an explicit currency; use brick/money or moneyphp in production.
  • Verify webhooks over the raw body with HMAC, a timestamp window and hash_equals(), then deduplicate by event id.
  • Every endpoint that creates money movement is idempotent, and every job that touches money assumes at-least-once delivery.
  • Keep PCI scope small with hosted payment fields; keep secrets out of files; treat composer.lock and composer audit as compliance artifacts.
  • PHP-FPM scales horizontally with ease; long-running runtimes buy latency at the cost of state discipline; numeric and long-lived-connection workloads belong in another service.
  • A sound default for a MENA fintech: Laravel or Symfony for the transactional surface, PostgreSQL as the ledger, queues for every third-party call, and a plan to extract specialised services when they earn it.