# Place MetaTrader 5 orders from PHP — no MetaTrader installed

URL: https://docs.fxapis.com/guides/php

> A complete PHP program using Guzzle that connects an MT5 account, places a market order with an idempotency key, handles SEND_FAILED and ORDER_UNRESOLVED…



This guide builds one PHP script that trades a MetaTrader 5 account end to end:
connect the account, bring it online, place a market order, read the position,
close it, take the account offline. It runs on any PHP host — shared hosting, a
Laravel or Symfony app, a queue worker, a cron job — because there is no
MetaTrader terminal on your side. The terminal runs in our cloud; your code
makes HTTPS requests with [Guzzle](https://docs.guzzlephp.org/).

<Callout type="warn" title="Use a demo account">
  `fx_test_` keys are a label, not a sandbox — every key reaches a real broker. Run this with a
  **broker demo account** until you are sure your code does what you expect.
</Callout>

## What you need [#what-you-need]

* PHP 8.1 or newer with the `curl` and `json` extensions, and Composer.
* An fxapis API key with the `accounts:write`, `trading:execute` and `trading:read` scopes
  ([create one in the console](https://fxapis.com/dashboard/keys)).
* An MT5 **demo** account: its login number, its **trading** password (not the investor password),
  and the server name exactly as MetaTrader shows it. See
  [Connect MT5 accounts](/guides/connect-accounts) if you are unsure which is which.

## Set up [#set-up]

```bash
mkdir mt5-trade && cd mt5-trade
composer require guzzlehttp/guzzle:^7

export FXAPIS_KEY="fx_test_…"          # your API key
export MT5_LOGIN="26177561"
export MT5_SERVER="VantageMarkets-Demo"
export MT5_PASSWORD="…"                # the trading password
export MT5_SYMBOL="EURUSD"             # as your broker names it
```

Keep the key and the password in environment variables or your framework's
secret store — never in source code, never in a log line.

## The complete program [#the-complete-program]

Save this as `trade.php` and run `php trade.php`.

```php title="trade.php"
<?php
// Connect an MT5 account through fxapis, open a small trade, and close it.
// Run it against a broker DEMO account. Every fxapis key reaches a real broker.
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
use Psr\Http\Message\ResponseInterface;

const NEEDS_ATTENTION = ['invalid_credentials', 'trading_disabled', 'needs_2fa', 'needs_certificate'];
// Codes that mean "nothing was placed yet — ask again with the SAME key".
const RETRY_SAME_KEY = ['SEND_FAILED', 'IDEMPOTENCY_IN_FLIGHT', 'ACCOUNT_NOT_READY', 'NO_RUNTIME', 'RATE_LIMITED'];

final class ApiError extends RuntimeException
{
    public function __construct(
        public readonly int $status,
        public readonly string $errorCode,
        string $message,
        public readonly array $details = [],
    ) {
        parent::__construct("$status $errorCode: $message");
    }
}

function setting(string $name, ?string $default = null): string
{
    $value = getenv($name);
    if ($value === false || $value === '') {
        if ($default === null) {
            throw new RuntimeException("Set $name");
        }
        return $default;
    }
    return $value;
}

$http = new Client([
    'base_uri' => setting('FXAPIS_API', 'https://api.fxapis.com'),
    'headers' => ['Authorization' => 'Bearer ' . setting('FXAPIS_KEY')],
    'http_errors' => false, // we read every status ourselves
    'timeout' => 30,
]);

/** The error object of a failed response, or null if the body is not ours (a proxy page). */
function errorOf(ResponseInterface $response): ?array
{
    $body = json_decode((string) $response->getBody(), true);
    return is_array($body) && isset($body['error']['code']) ? $body['error'] : null;
}

function fail(ResponseInterface $response): never
{
    $error = errorOf($response) ?? ['code' => 'HTTP_ERROR', 'message' => $response->getReasonPhrase()];
    throw new ApiError($response->getStatusCode(), $error['code'], $error['message'], $error['details'] ?? []);
}

/** Sends a request and returns `data`, or throws ApiError. */
function api(Client $http, string $method, string $path, ?array $options = []): mixed
{
    $response = $http->request($method, $path, $options ?? []);
    if ($response->getStatusCode() >= 300) {
        fail($response);
    }
    return json_decode((string) $response->getBody(), true)['data'];
}

/** Stores the account at fxapis. It does not log in yet. */
function connectAccount(Client $http, string $login, string $server, string $password): array
{
    try {
        return api($http, 'POST', '/v1/accounts', ['json' => [
            'login' => $login,
            'server' => $server,
            'password' => $password,
            'mode' => 'warm_on_demand',
            'label' => 'php-guide',
        ]]);
    } catch (ApiError $e) {
        if ($e->errorCode !== 'ACCOUNT_EXISTS') {
            throw $e;
        }
        // Connected on an earlier run: find it instead of connecting it twice.
        foreach (api($http, 'GET', '/v1/accounts') as $account) {
            if ($account['login'] === $login && $account['server'] === $server) {
                return $account;
            }
        }
        throw $e;
    }
}

/** Starts the broker login, then polls until the account is ready to trade. */
function bringOnline(Client $http, string $accountId, int $timeout = 120): array
{
    api($http, 'POST', "/v1/accounts/$accountId/warm"); // 409 NEEDS_OPERATOR / NO_SECRET throws
    $deadline = time() + $timeout;
    while (time() < $deadline) {
        $status = api($http, 'GET', "/v1/accounts/$accountId/status");
        if ($status['state'] === 'ready') {
            return $status;
        }
        if (in_array($status['state'], NEEDS_ATTENTION, true)) {
            // Not retryable. Repeated failed logins are how brokers lock accounts.
            throw new RuntimeException("account needs attention: {$status['state']} ({$status['detail']})");
        }
        sleep(2);
    }
    throw new RuntimeException("account not ready after {$timeout}s");
}

/** After ORDER_UNRESOLVED: never resend. Poll the order until the broker's record settles it. */
function waitForOutcome(Client $http, string $orderId, int $timeout = 300): array
{
    $deadline = time() + $timeout;
    while (time() < $deadline) {
        $order = api($http, 'GET', "/v1/orders/$orderId");
        if ($order['state'] !== 'unknown') {
            return $order;
        }
        sleep(5);
    }
    throw new RuntimeException("order $orderId is still unknown. Do not resend it; check it again later.");
}

/** Places an order (or a close) exactly once, however many times the request is retried. */
function sendOrder(Client $http, string $path, array|object $body, int $attempts = 6): array
{
    $key = bin2hex(random_bytes(16)); // one key per order, reused on every retry
    for ($attempt = 0; $attempt < $attempts; $attempt++) {
        $backoff = min(2 ** $attempt, 10);
        try {
            $response = $http->request('POST', $path, [
                'json' => $body,
                'headers' => ['Idempotency-Key' => $key],
                'timeout' => 90,
            ]);
        } catch (TransferException) {
            sleep($backoff); // we never saw the answer; the same key makes asking again safe
            continue;
        }

        if ($response->getStatusCode() === 201) {
            return json_decode((string) $response->getBody(), true)['data'];
        }

        $error = errorOf($response);
        if ($error === null && $response->getStatusCode() >= 500) {
            sleep($backoff); // not our error body: a gateway hiccup
            continue;
        }
        if ($error === null) {
            fail($response);
        }

        if ($error['code'] === 'ORDER_UNRESOLVED') {
            return waitForOutcome($http, $error['details'][0]['orderId']);
        }
        if (in_array($error['code'], RETRY_SAME_KEY, true)) {
            $retryAfter = (int) $response->getHeaderLine('retry-after');
            sleep($retryAfter > 0 ? $retryAfter : $backoff);
            continue;
        }
        fail($response); // ORDER_REJECTED, INVALID_REQUEST, QUOTA_EXCEEDED, …
    }
    throw new RuntimeException("gave up after $attempts attempts; retrying later with key $key is still safe");
}

$login = setting('MT5_LOGIN');
$server = setting('MT5_SERVER');
$symbol = setting('MT5_SYMBOL', 'EURUSD');

$account = connectAccount($http, $login, $server, setting('MT5_PASSWORD'));
$id = $account['id'];
echo "account $id ({$account['state']})\n";

bringOnline($http, $id);
echo "online\n";

try {
    $order = sendOrder($http, "/v1/accounts/$id/orders/market", [
        'symbol' => $symbol,
        'side' => 'buy',
        'volume' => '0.01', // a string, never a float
    ]);
} catch (ApiError $e) {
    if ($e->errorCode === 'ORDER_REJECTED') {
        echo "rejected by the broker: {$e->getMessage()}\n";
        exit(1);
    }
    throw $e;
}
echo "order {$order['id']}: {$order['state']} at {$order['filledPrice']}\n";
if (!in_array($order['state'], ['filled', 'partially_filled'], true)) {
    exit(1);
}

// Positions refresh on their own about every 15 s; reconcile (optional) refreshes them now.
api($http, 'POST', "/v1/accounts/$id/reconcile");
foreach (api($http, 'GET', "/v1/accounts/$id/positions") as $p) {
    echo "  #{$p['brokerPositionId']} {$p['side']} {$p['volume']} {$p['symbol']} @ {$p['openPrice']}"
        . " profit {$p['profit']} (seen {$p['observedAt']})\n";
}

$close = sendOrder($http, "/v1/accounts/$id/positions/{$order['brokerPositionId']}/close", new stdClass());
echo "closed: {$close['state']} at {$close['filledPrice']}\n";

// Offline now rather than after the idle window. The stored password is kept.
api($http, 'POST', "/v1/accounts/$id/cool");

if (getenv('FXAPIS_DISCONNECT') === '1') {
    // Erases the stored password for good. Only when you are done with this account.
    $result = api($http, 'POST', "/v1/accounts/$id/disconnect");
    echo 'disconnected, credentials removed: ' . var_export($result['credentialsRemoved'], true) . "\n";
}
```

A run against a demo account prints something like:

```
account 0d7c1f5e-4b6a-4c1e-9f0a-2d9e8c7b6a51 (created)
online
order b1c7…9e2a: filled at 1.13426
  #2109584418 buy 0.01 EURUSD @ 1.13426 profit -0.07 (seen 2026-09-30T10:14:03.512Z)
closed: filled at 1.13419
```

## Authentication [#authentication]

The Guzzle client is created once with the base URL and the
`Authorization: Bearer …` header, and with `http_errors` off so that every
status reaches your code instead of becoming an exception — trading needs to
tell a 409 from a 502 by its `code`. In a framework, register one client as a
service and reuse it. Scopes and key rotation: [Authentication](/authentication).

## Connecting the account [#connecting-the-account]

`POST /v1/accounts` stores the account and encrypts the password. It does not
log in, so it answers at once with the account in `created`. Store the
returned `id` against your user and discard the password — never write it to
your database, a queue payload or a log.

A login and server can be connected once per workspace; on a second run the
script receives **409** `ACCOUNT_EXISTS` and finds the account with
`GET /v1/accounts`. Reference:
[Connect an MT5 account](/api-reference/accounts/connect-an-mt5-account).

## Bringing it online [#bringing-it-online]

`POST /v1/accounts/{id}/warm` answers **202** at once while the broker login
runs. `bringOnline` polls `GET /v1/accounts/{id}/status` every two seconds
until `ready`, which usually takes about ten seconds in our tests.

A needs-attention state — `invalid_credentials`, `trading_disabled`,
`needs_2fa`, `needs_certificate` — ends the wait immediately: it needs a person,
not a retry. `warm` also answers **409** for such an account, so a wrong
password is never retried against the broker. See [Accounts](/accounts#states).

In a web request, do not block on this loop. Start `warm` from the request and
poll from the browser through your backend, or from a queue job.

## Placing the order safely [#placing-the-order-safely]

`sendOrder` generates one `Idempotency-Key` per order and sends the **same**
key on every retry, so the order is placed at most once whatever happens on
the network. Keys are remembered for 24 hours.

Volumes and prices are strings — `'0.01'`. PHP floats are doubles; if you
compute a size, use `bcmath` or integer lot steps and send the string.

| Response                                  | What it means                                                     | What `sendOrder` does                                                                                   |
| ----------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| **201**                                   | The broker answered; `state` is `filled` (or `partially_filled`). | Returns the order.                                                                                      |
| **422** `ORDER_REJECTED`                  | The broker refused it. Nothing executed.                          | Throws. If `details[0].retryable` is true (the price moved), a *new* decision with a *new* key is fine. |
| **409** `ACCOUNT_NOT_READY`, `NO_RUNTIME` | The account was not online in time. Nothing was sent.             | Retries with the same key.                                                                              |
| **409** `IDEMPOTENCY_IN_FLIGHT`           | The first attempt with this key is still running.                 | Retries with the same key.                                                                              |
| **502** `SEND_FAILED`                     | Proven never to have reached the broker.                          | Retries with the same key.                                                                              |
| **502** `ORDER_UNRESOLVED`                | We do not know yet whether it executed.                           | **Never resends.** Polls `GET /v1/orders/{id}` until it leaves `unknown`.                               |
| **429** `RATE_LIMITED`                    | Too many requests of this kind.                                   | Waits `retry-after` seconds, same key.                                                                  |
| Timeout or network error                  | You did not see the answer.                                       | Retries with the same key; the server replays the first answer.                                         |

`ORDER_UNRESOLVED` means the order may be live at the broker. Resending it is
how an account ends up with two positions. We confirm it against the broker's
records automatically; the order then moves to what really happened. See
[Idempotent MT5 orders](/guides/idempotency).

If you place orders from a queue job, derive the key from the job instead of
generating it inside `sendOrder` — for example `signal_{$signalId}:user_{$userId}`
— so a job that is retried by the queue also reuses its key.

## Reading positions [#reading-positions]

`GET /v1/accounts/{id}/positions` lists open positions with
`brokerPositionId`, `side`, `volume`, `openPrice`, `currentPrice`, `stopLoss`,
`takeProfit`, `profit` and `observedAt`. Rows refresh about every 15 seconds
while the account is online, and `observedAt` says how fresh each one is;
`POST /v1/accounts/{id}/reconcile` refreshes them now. Reference:
[Positions on an account](/api-reference/trading/positions-on-an-account).

## Closing the position [#closing-the-position]

`POST /v1/accounts/{id}/positions/{ticket}/close` with an empty JSON object
closes the whole position — note `new stdClass()`, because Guzzle encodes an
empty PHP array as `[]`, not `{}`. Send `['volume' => '0.005']` to close part.
The ticket is `brokerPositionId` from the order. Always close through
`sendOrder`: a repeated close on a hedging account opens an opposite position.

A close of a position opened moments ago refreshes positions first;
`POSITION_NOT_FOUND` means it is already closed. The program's `reconcile`
call after the fill is optional — it only makes the positions listing current
at once.

## Offline and disconnect [#offline-and-disconnect]

`POST /cool` takes the account offline and keeps the stored password. Left
alone, a `warm_on_demand` account goes offline after 15 idle minutes; stops
and take profits keep working at the broker either way.

`POST /disconnect` erases the stored password for good — use it when a customer
leaves. The script calls it only with `FXAPIS_DISCONNECT=1`, so you can run it repeatedly. Connecting a disconnected login again brings the same account back, with its history.

## Next steps [#next-steps]

* [Signals and click-to-trade](/signals) — a full member-facing integration.
* [Connect MT5 accounts](/guides/connect-accounts) — the connect screen, done well.
* [Errors](/errors) and the [API reference](/api-reference).
