# PHP

Call Formerie from any PHP site with `ext-curl`, which most PHP installs have, with no Composer needed. You don't need a Formerie package or an API key: a form's public address is all it takes.

## Submit a form

Save this as `submit.php`, and run `php submit.php`.

```php
<?php

$runtimeUrl = getenv('FORMERIE_RUNTIME_URL') ?: 'https://forms.formerie.com';
// Your form. This is the only line that names it.
$form = getenv('FORMERIE_FORM') ?: 'acme/contact';

$curl = curl_init("$runtimeUrl/$form/submit");
curl_setopt_array($curl, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 10,
    CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
    CURLOPT_POSTFIELDS => json_encode([
        'fields' => [
            'name' => 'Jane Example',
            'email' => 'jane@example.com',
            'message' => 'Hello from my server',
        ],
    ], JSON_THROW_ON_ERROR),
]);
$body = curl_exec($curl);
if ($body === false) {
    exit('Could not reach Formerie: ' . curl_error($curl) . "\n");
}
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$result = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
echo $status, ' ', $result['ok'] ? $result['submissionUid'] : $result['error']['code'], "\n";
```

Replace `acme/contact`, on the marked line, with your own handles. A success is HTTP `202`, with a `submissionUid` such as `sub_p6dr8wq2j7tkan`: the answers are stored, and their email is queued. Each sample reads two optional settings from the environment, `FORMERIE_RUNTIME_URL` for a hostname you've connected, and `FORMERIE_FORM` for the handles.

## Before you start

1. [Publish a form](/guides/publish/), and take its workspace and form handles from the **Publish** tab. Your server doesn't need to be in the form's allowed websites.
2. To try things without emailing anyone, create a test key on the form's **Tests** tab, and put it in an environment variable called `FORMERIE_TEST_KEY`:

```php
<?php

$runtimeUrl = getenv('FORMERIE_RUNTIME_URL') ?: 'https://forms.formerie.com';
// Your form. This is the only line that names it.
$form = getenv('FORMERIE_FORM') ?: 'acme/contact';

$curl = curl_init("$runtimeUrl/$form/submit");
curl_setopt_array($curl, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 10,
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
        // Keep the key out of your code. A test is stored apart and emails nobody.
        'X-Formerie-Test-Key: ' . getenv('FORMERIE_TEST_KEY'),
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'fields' => ['name' => 'Jane Example', 'email' => 'jane@example.com', 'message' => 'Just a test'],
    ], JSON_THROW_ON_ERROR),
]);
$body = curl_exec($curl);
if ($body === false) {
    exit('Could not reach Formerie: ' . curl_error($curl) . "\n");
}
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$result = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
echo $status, ' ', $result['ok'] ? $result['submissionUid'] : $result['error']['code'], "\n";
```

A test comes back as `tst_...`, is stored apart, and emails nobody. A wrong key gets `TEST_KEY_INVALID`, HTTP `401`, and nothing is stored. Remove the header before you go live, since a key left in place files everything as a test. See [Test from a server or CI](/guides/test-your-form/#test-from-a-server-or-ci).

## Check answers without submitting

`validate` checks answers and stores nothing, so you can flag a problem early. It answers `200` when they pass, and `422` with the same errors as a submission when they don't. The final submission always checks the whole form again.

```php
<?php

function formerieUrl(string $action): string
{
    $runtimeUrl = getenv('FORMERIE_RUNTIME_URL') ?: 'https://forms.formerie.com';
    // Your form. This is the only line that names it.
    $form = getenv('FORMERIE_FORM') ?: 'acme/contact';

    return "$runtimeUrl/$form/$action";
}

/** Checks answers and stores nothing. With no step, only the fields you send are checked. */
function checkAnswers(array $fields, ?string $step = null): array
{
    $payload = $step === null
        ? ['mode' => 'partial', 'fields' => $fields]
        : ['mode' => 'step', 'step' => $step, 'fields' => $fields];

    $curl = curl_init(formerieUrl('validate'));
    curl_setopt_array($curl, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 10,
        CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
        CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
    ]);
    $body = curl_exec($curl);
    if ($body === false) {
        throw new RuntimeException(curl_error($curl));
    }

    $result = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
    if (!$result['ok'] && $result['error']['code'] !== 'VALIDATION_FAILED') {
        throw new RuntimeException($result['error']['code']);
    }

    return $result;
}
```

By default, only the fields you send are checked. Pass a step to check every field in that step instead, on a form with [steps](/guides/multi-step-forms/). A step the form doesn't have gets `BAD_REQUEST`.

## Show errors per field

Answers that fail come back as `VALIDATION_FAILED`, HTTP `422`, and nothing is stored. `error.fields` holds the problems by field, and `error.issues` holds the same ones in order. Each has a `path`, a `code` to match on, and a `message` that's safe to show, in the form's language.

```php
<?php

function submitAnswers(array $fields): array
{
    $runtimeUrl = getenv('FORMERIE_RUNTIME_URL') ?: 'https://forms.formerie.com';
    // Your form. This is the only line that names it.
    $form = getenv('FORMERIE_FORM') ?: 'acme/contact';

    $curl = curl_init("$runtimeUrl/$form/submit");
    curl_setopt_array($curl, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 10,
        CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
        CURLOPT_POSTFIELDS => json_encode(['fields' => $fields], JSON_THROW_ON_ERROR),
    ]);
    $body = curl_exec($curl);
    if ($body === false) {
        throw new RuntimeException(curl_error($curl));
    }

    return json_decode($body, true, flags: JSON_THROW_ON_ERROR);
}

/** Finds the visitor's answer for a path such as contact.email, so the form can show it again. */
function answerAt(array $answers, string $path): string
{
    $value = $answers;
    foreach (explode('.', $path) as $part) {
        $value = is_array($value) ? ($value[$part] ?? '') : '';
    }

    return is_scalar($value) ? (string) $value : '';
}

/** One input with its answer kept and its first message under it. */
function renderField(string $path, array $answers, array $result): string
{
    $name = htmlspecialchars($path);
    $value = htmlspecialchars(answerAt($answers, $path));
    $message = $result['error']['fields'][$path][0]['message'] ?? null;
    $error = $message === null ? '' : '<p class="error">' . htmlspecialchars($message) . '</p>';

    return "<input name=\"$name\" value=\"$value\">$error";
}
```

A field inside a group has a path such as `contact.email`, and one inside a list has `guests[0].email`, so split the path to find its answer. See [Field paths](/guides/validation/#field-paths). Show the visitor's answers again with the messages, escaped, so they only have to fix what's wrong.

## Retry safely

A network failure doesn't tell you whether the submission arrived. So send one `metadata.idempotencyKey` per submission, the same on every retry. Formerie then counts and delivers it once, and answers a repeat with `DUPLICATE_SUBMISSION`, HTTP `409`, which means the first one worked.

```php
<?php

const MAX_ATTEMPTS = 3;
const LONGEST_WAIT = 30;
const TEMPORARY_CODES = ['FORM_MAINTENANCE', 'QUEUE_FAILED', 'INTERNAL_ERROR'];

/** One attempt. Says what happened, and whether another attempt could help. */
function sendOnce(array $fields, string $key): array
{
    $runtimeUrl = getenv('FORMERIE_RUNTIME_URL') ?: 'https://forms.formerie.com';
    // Your form. This is the only line that names it.
    $form = getenv('FORMERIE_FORM') ?: 'acme/contact';
    $after = null;

    $curl = curl_init("$runtimeUrl/$form/submit");
    curl_setopt_array($curl, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 10,
        CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
        CURLOPT_POSTFIELDS => json_encode([
            'fields' => $fields,
            'metadata' => ['idempotencyKey' => $key],
        ], JSON_THROW_ON_ERROR),
        CURLOPT_HEADERFUNCTION => function ($curl, string $header) use (&$after): int {
            if (preg_match('/^retry-after:\s*(\d+)\s*$/i', $header, $match)) {
                $after = (int) $match[1];
            }

            return strlen($header);
        },
    ]);
    $body = curl_exec($curl);
    if ($body === false) {
        return ['outcome' => 'retry-later', 'reason' => 'network', 'again' => true];
    }
    $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);

    // Only the short-term limit sends Retry-After. A form's own limit can last an hour, so don't wait for it.
    if ($status === 429) {
        return [
            'outcome' => 'retry-later',
            'reason' => 'rate-limited',
            'after' => $after,
            'again' => $after !== null && $after <= LONGEST_WAIT,
        ];
    }

    // Something in front of Formerie may answer without JSON, so look at the status first.
    $result = json_decode($body, true);
    if (!is_array($result)) {
        return ['outcome' => 'retry-later', 'reason' => 'bad-response', 'again' => in_array($status, [502, 503, 504], true)];
    }

    if ($result['ok']) {
        return ['outcome' => 'accepted', 'id' => $result['submissionUid']];
    }
    $error = $result['error'];
    if ($error['code'] === 'DUPLICATE_SUBMISSION') {
        // The first send worked, and only its answer was lost.
        return ['outcome' => 'accepted', 'duplicate' => true];
    }
    if (in_array($error['code'], TEMPORARY_CODES, true)) {
        return ['outcome' => 'retry-later', 'reason' => $error['code'], 'again' => true];
    }
    if ($error['code'] === 'VALIDATION_FAILED') {
        return ['outcome' => 'invalid', 'code' => $error['code'], 'issues' => $error['issues']];
    }

    return ['outcome' => 'rejected', 'code' => $error['code'], 'message' => $error['message']];
}

/** One key per submission, the same on every retry. Store it with the answers if you retry later. */
function submitWithRetries(array $fields, ?string $key = null): array
{
    $key ??= bin2hex(random_bytes(16));
    for ($attempt = 1; ; $attempt++) {
        $answer = sendOnce($fields, $key);
        $again = $answer['again'] ?? false;
        unset($answer['again']);
        if (!$again || $attempt === MAX_ATTEMPTS) {
            return $answer + ['key' => $key, 'attempts' => $attempt];
        }
        $backoff = random_int(0, min(250 * 2 ** $attempt, 4000)) / 1000;
        usleep((int) (max($answer['after'] ?? 0, $backoff) * 1_000_000));
    }
}
```

| Answer | Retry? |
| --- | --- |
| No connection, or a timeout | Yes, with the same key |
| `429` with `Retry-After` | Yes, after that wait, if it's short. The short-term limit answers with `Retry-After: 60` |
| `429` with no `Retry-After` | No. A form's own limit can last an hour, so ask the visitor to try again later |
| `FORM_MAINTENANCE`, `QUEUE_FAILED`, `INTERNAL_ERROR` | Yes, after a short wait |
| `502`, `503` or `504` with no JSON | Yes. Something in front of Formerie answered |
| Any other answer with no JSON | No. Keep the key, and look into it |
| `DUPLICATE_SUBMISSION` | No. Treat it as a success |
| `VALIDATION_FAILED` and any other code | No. The answer won't change |

- The key must be a non-empty string. Anything else is ignored, and there's no repeat check.
- A repeat is noticed for 300 seconds by default, under the form's repeat window. See [What the repeat window covers](/guides/spam-protection/#what-the-repeat-window-covers).
- The check comes first, so a key that went through gets `DUPLICATE_SUBMISSION` even with different answers. Use a new key for each new submission. A key refused for its answers was never accepted, so it can go again once they're fixed.
- A retry after `QUEUE_FAILED` can store a second copy, which you'd see under **Submissions**. It's rare.

## Human verification

If your form requires human verification, use your own keys, with your site in the provider's list of domains and in the form's allowed websites. The widget adds its token to your form. Forward two things:

1. **The token,** as `verificationToken`, taken out of the fields.
2. **The `Origin` of the visitor's request,** because Formerie compares the token's domain with it. A server sends none by itself, and the check then fails with `TURNSTILE_FAILED`.

```php
<?php

/** $posted is what your form sent, such as $_POST. $origin is $_SERVER['HTTP_ORIGIN'] ?? ''. */
function submitVerified(array $posted, string $origin): array
{
    $runtimeUrl = getenv('FORMERIE_RUNTIME_URL') ?: 'https://forms.formerie.com';
    // Your form. This is the only line that names it.
    $form = getenv('FORMERIE_FORM') ?: 'acme/contact';

    $token = $posted['cf-turnstile-response'] ?? '';
    unset($posted['cf-turnstile-response']);

    $headers = ['Content-Type: application/json'];
    // Without the visitor's Origin, the provider's hostname can't match, and the check fails.
    if ($origin !== '') {
        $headers[] = "Origin: $origin";
    }

    $curl = curl_init("$runtimeUrl/$form/submit");
    curl_setopt_array($curl, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 10,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_POSTFIELDS => json_encode(['fields' => $posted, 'verificationToken' => $token], JSON_THROW_ON_ERROR),
    ]);
    $body = curl_exec($curl);
    if ($body === false) {
        throw new RuntimeException(curl_error($curl));
    }

    return json_decode($body, true, flags: JSON_THROW_ON_ERROR);
}
```

An `Origin` that isn't in the form's allowed websites gets `FORBIDDEN_ORIGIN`. Only forward it for this: on any other form, it turns on a check your server doesn't otherwise need. The widget needs JavaScript in the visitor's browser. See [Verification on the server path](/guides/framework-entry-points/#verification-on-the-server-path).

## Spam checks

Hosted pages and embeds send this metadata for you. From your server, send it yourself, or those checks are skipped.

```php
<?php

function submitWithMetadata(array $fields, DateTimeImmutable $startedAt, string $visitorId): array
{
    $runtimeUrl = getenv('FORMERIE_RUNTIME_URL') ?: 'https://forms.formerie.com';
    // Your form. This is the only line that names it.
    $form = getenv('FORMERIE_FORM') ?: 'acme/contact';

    $curl = curl_init("$runtimeUrl/$form/submit");
    curl_setopt_array($curl, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 10,
        CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
        CURLOPT_POSTFIELDS => json_encode([
            'fields' => $fields,
            'metadata' => [
                'idempotencyKey' => bin2hex(random_bytes(16)),
                // When the visitor opened the form, for the minimum submit time check.
                'startedAt' => $startedAt->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z'),
                'pageUrl' => 'https://www.example.com/contact',
                // Your own visitor ID, for a limit rule with the deviceToken scope.
                'deviceToken' => $visitorId,
            ],
        ], JSON_THROW_ON_ERROR),
    ]);
    $body = curl_exec($curl);
    if ($body === false) {
        throw new RuntimeException(curl_error($curl));
    }

    return json_decode($body, true, flags: JSON_THROW_ON_ERROR);
}
```

| Field | What it's for |
| --- | --- |
| `idempotencyKey` | The repeat check, as above |
| `startedAt` | When the visitor opened the form, as an ISO 8601 time. Sent sooner than the form's minimum, 1 second by default, it gets `SPAM_REJECTED`. Keep the real time from when you showed the form, not the time of this call. One that can't be read skips the check |
| `pageUrl` | The page the form was sent from. Stored only if it's an http or https address |
| `deviceToken` | Your own visitor ID, for a limit per visitor, below |

The hidden field check runs when your form sends that field, and links and human verification run on the server either way. See [Spam and limits](/guides/spam-protection/).

## Limits from your server

Formerie counts visitors by network address, and from here, that's your server's. **Every visitor counts as your server.**

| Limit | Default | From your server |
| --- | --- | --- |
| Per visitor | 30 submissions every 10 minutes | Shared by all your visitors. Raise it before you launch, or leave it empty for no limit, under **Settings**, then **Submission limits** |
| Whole form | 1,000 submissions an hour | The same. On the Free plan, it's also the most a form can accept |
| Short-term | 120 requests a minute for each form from one address | Can't be changed. It's the only one that sends `Retry-After`. `validate` counts here too |

Over any of them, you get `RATE_LIMITED`, HTTP `429`. To keep a limit for each visitor, a rule with the `deviceToken` scope counts by the ID you send, such as a hash of the visitor's address. The app's settings don't offer that rule yet, so [talk to us](https://formerie.com/contact/) to set one up, and keep the whole-form limit, since anyone calling the form directly can change their ID. See [Limits on the server path](/guides/framework-entry-points/#limits-on-the-server-path) for the reasoning, and [Plans and limits](/guides/plans-and-limits/) for monthly counts.

## Laravel

This works through the API: the route reads the visitor's form, and calls Formerie with Laravel's `Http` client. It was run against a local Formerie backend with Laravel 13, as `routes/web.php`. It answers `202` with the submission's ID, the same status as Formerie with messages by field, or `503` when it can't reach Formerie. Keep `@csrf` in your form. `Http` doesn't throw on a `422`, so its fields can be read.

```php
<?php

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;

Route::post('/contact', function (Request $request) {
    $runtimeUrl = env('FORMERIE_RUNTIME_URL', 'https://forms.formerie.com');
    // Your form. This is the only line that names it.
    $form = env('FORMERIE_FORM', 'acme/contact');
    try {
        $answer = Http::timeout(10)->post("$runtimeUrl/$form/submit", [
            // Name the fields you send, so a visitor can't add others.
            'fields' => $request->only(['name', 'email', 'message']),
        ]);
    } catch (ConnectionException) {
        // No connection or a timeout. Keep the visitor's answers on screen.
        return response()->json(['code' => 'TRY_AGAIN'], 503);
    }
    $result = $answer->json();
    if (!is_array($result)) {
        // Something in front of Formerie answered without JSON.
        return response()->json(['code' => 'TRY_AGAIN'], 503);
    }
    if ($result['ok']) {
        return response()->json(['id' => $result['submissionUid']], 202);
    }
    $errors = array_map(fn (array $issues) => array_column($issues, 'message'), $result['error']['fields'] ?? []);

    return response()->json(['code' => $result['error']['code'], 'errors' => $errors], $answer->status());
});
```

## Symfony

This works through the API too, with Symfony's `HttpClient`, and it was run the same way with Symfony 7.4. Route it to `POST /contact`. It answers the same way as the Laravel route.

```bash
composer require symfony/http-client
```

```php
<?php

namespace App;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

class ContactController
{
    public function __construct(private HttpClientInterface $client)
    {
    }

    public function __invoke(Request $request): JsonResponse
    {
        $runtimeUrl = getenv('FORMERIE_RUNTIME_URL') ?: 'https://forms.formerie.com';
        // Your form. This is the only line that names it.
        $form = getenv('FORMERIE_FORM') ?: 'acme/contact';
        try {
            $answer = $this->client->request('POST', "$runtimeUrl/$form/submit", [
                'timeout' => 10,
                'max_duration' => 10,
                // Name the fields you send, so a visitor can't add others.
                'json' => ['fields' => [
                    'name' => $request->request->getString('name'),
                    'email' => $request->request->getString('email'),
                    'message' => $request->request->getString('message'),
                ]],
            ]);
            // false, so a 422 is read and doesn't throw.
            $result = $answer->toArray(false);
        } catch (ExceptionInterface) {
            // No connection, a timeout, or an answer that isn't JSON. Keep the visitor's answers on screen.
            return new JsonResponse(['code' => 'TRY_AGAIN'], 503);
        }
        if ($result['ok']) {
            return new JsonResponse(['id' => $result['submissionUid']], 202);
        }
        $errors = array_map(fn (array $issues) => array_column($issues, 'message'), $result['error']['fields'] ?? []);

        return new JsonResponse(['code' => $result['error']['code'], 'errors' => $errors], $answer->getStatusCode());
    }
}
```

## Good to know

- cURL has no overall limit by default, so keep `CURLOPT_TIMEOUT` on every call.
- Keep test keys and answers out of your logs. A `requestId` in an error, or the `X-Request-Id` header, is what to log and to quote when you ask for help.
- Match on `error.code`, never on the message, since a form can reword its messages and a status can cover several codes. [Error codes](/guides/error-codes/) lists them all.

| With | Note |
| --- | --- |
| Plain PHP | Answers are in `$_POST`, and the visitor's `Origin` is in `$_SERVER['HTTP_ORIGIN']`. Name the fields you send rather than passing `$_POST` along. |
| Guzzle | Pass `'http_errors' => false`, or a `422` throws before you can read its fields. Use `'json'` for the body and `'timeout'` for the limit. |
| PHP 8.5 | `curl_close()` is deprecated, and does nothing since PHP 8.0. These samples don't call it. |

The routes, fields and every error are in the [API reference](/guides/api-reference/).
