> ## Documentation Index
> Fetch the complete documentation index at: https://developer.jtl-software.com/llms.txt
> Use this file to discover all available pages before exploring further.

# PHP

> Build the Slim backend for your JTL Cloud App. Verify session tokens, fetch access tokens, and proxy requests to the JTL Cloud API.

Build the Slim 4 backend for a JTL Cloud App using PHP 8 and the built-in `sodium_crypto_sign_verify_detached` function for Ed25519 signature verification. By the end, the backend runs locally and the frontend's "Waiting for backend" placeholder turns into a real connection.

**Stack:** PHP 8.1+, Slim 4, Guzzle for HTTP requests, `vlucas/phpdotenv` for environment variables, native `ext-sodium` for JWT verification.

## Prerequisites

You need:

* ✅ **A finished frontend** from the [Build the Frontend](/get-started/quick-start/from-scratch/frontend) page, running locally on `http://localhost:5173`
* ✅ **[PHP](https://www.php.net/downloads) 8.1 or higher** with `ext-sodium` enabled. Run `php --version` and `php -m | grep sodium` to check.
* ✅ **[Composer](https://getcomposer.org/)** for dependency management. Run `composer --version` to check.

## What you're Building

During setup, your backend verifies that the session token from your frontend is valid and was issued by JTL.

To do this:

* Your backend authenticates with JTL using its client credentials
* Fetches JTL's public keys (JWKS)
* And uses them to verify the session token's signature

```mermaid theme={null}
sequenceDiagram
    participant FE as Your Frontend
    participant BE as Your Backend (Slim)
    participant Auth as JTL Auth
    participant API as JTL Cloud API

    FE->>BE: GET /api/connect-tenant (X-Session-ID: token)
    BE->>Auth: POST /oauth2/token (client credentials)
    Auth-->>BE: Access token (JWT)
    BE->>API: GET /.well-known/jwks.json (with access token)
    API-->>BE: Public keys (JWKS)
    BE->>BE: Verify session token signature
    BE-->>FE: Verified tenant info
```

Once verified, the token tells your backend which tenant (merchant) and user is using your app. Your backend can then make tenant-scoped requests to the JTL Cloud API on their behalf.

## 1. Set up the Project

Create a `backend` folder alongside the existing `frontend` folder, then initialise a Composer project inside it.

```bash theme={null}
cd my-jtl-app
mkdir backend
cd backend
composer init --name="my-jtl-app/backend" --type=project --no-interaction
```

Your project structure now looks like this:

```
my-jtl-app/
├── frontend/      # From the previous page
└── backend/       # New
```

## 2. Install Packages

```bash theme={null}
composer require slim/slim slim/psr7 guzzlehttp/guzzle vlucas/phpdotenv
```

| Package             | Purpose                                                            |
| ------------------- | ------------------------------------------------------------------ |
| `slim/slim`         | The Slim 4 micro-framework: routing, request and response handling |
| `slim/psr7`         | The PSR-7 implementation Slim uses by default                      |
| `guzzlehttp/guzzle` | HTTP client for making requests to JTL APIs                        |
| `vlucas/phpdotenv`  | Loads environment variables from a `.env` file                     |

## 3. Set up Environment Variables

Create `backend/.env`:

```dotenv theme={null}
CLIENT_ID=your-client-id-here
CLIENT_SECRET=your-client-secret-here
PORT=5273
```

The frontend's Vite dev proxy is already configured to forward `/api/*` requests to `localhost:5273`, so the `PORT` value matches that target.

Add `.env` and `vendor/` to `backend/.gitignore` so secrets and dependencies don't end up in version control:

```
vendor
.env
```

## 4. Build the Auth Helper

The first piece of the backend is a function that authenticates with JTL using your client credentials and returns an access token. This token has two uses: fetching the public keys for session token verification, and making tenant-scoped calls to the JTL Cloud API.

Create `backend/src/JtlAuth.php`:

```php theme={null}
<?php

declare(strict_types=1);

namespace App;

use GuzzleHttp\ClientInterface;

class JtlAuth
{
    public const API_BASE_URL = 'https://api.jtl-cloud.com';
    public const AUTH_ENDPOINT = 'https://auth.jtl-cloud.com/oauth2/token';

    private ?string $cachedToken = null;
    private int $tokenExpiresAt = 0;

    public function __construct(
        private readonly string $clientId,
        private readonly string $clientSecret,
        private readonly ClientInterface $httpClient,
    ) {
    }

    public function getJwt(): string
    {
        if ($this->cachedToken !== null && time() < $this->tokenExpiresAt) {
            return $this->cachedToken;
        }

        if (empty($this->clientId) || empty($this->clientSecret)) {
            throw new \RuntimeException('CLIENT_ID and CLIENT_SECRET must be defined in .env');
        }

        $authString = base64_encode("{$this->clientId}:{$this->clientSecret}");

        $response = $this->httpClient->request('POST', self::AUTH_ENDPOINT, [
            'http_errors' => false,
            'headers' => [
                'Content-Type' => 'application/x-www-form-urlencoded',
                'Accept' => 'application/json',
                'Authorization' => "Basic {$authString}",
            ],
            'form_params' => ['grant_type' => 'client_credentials'],
        ]);

        $statusCode = $response->getStatusCode();
        $body = $response->getBody()->getContents();

        if ($statusCode < 200 || $statusCode >= 300) {
            throw new \RuntimeException("Failed to fetch JWT ({$statusCode}): {$body}");
        }

        $data = json_decode($body, true);
        $expiresIn = $data['expires_in'] ?? 3600;
        $this->cachedToken = $data['access_token'];
        $this->tokenExpiresAt = time() + $expiresIn - 30;

        return $this->cachedToken;
    }
}
```

The credentials are Base64-encoded and sent as a `client_credentials` grant to JTL's auth endpoint. The access token is cached in memory until 30 seconds before expiry, so repeat calls within the same request reuse it. For cross-request reuse, swap the in-memory cache for APCu or another shared cache.

## 5. Build the Session Verifier

With an access token in hand, the backend can fetch the public keys it needs to verify session tokens. The `sodium_crypto_sign_verify_detached` function verifies Ed25519 signatures directly against the public key.

Create `backend/src/SessionVerifier.php`:

```php theme={null}
<?php

declare(strict_types=1);

namespace App;

use GuzzleHttp\ClientInterface;

class SessionVerifier
{
    public function __construct(
        private readonly JtlAuth $auth,
        private readonly ClientInterface $httpClient,
    ) {
    }

    public function verify(string $sessionToken): array
    {
        $accessToken = $this->auth->getJwt();

        $response = $this->httpClient->request('GET', JtlAuth::API_BASE_URL . '/account/.well-known/jwks.json', [
            'http_errors' => false,
            'headers' => [
                'Authorization' => "Bearer {$accessToken}",
                'Accept' => 'application/json',
            ],
        ]);

        $statusCode = $response->getStatusCode();
        $body = $response->getBody()->getContents();

        if ($statusCode < 200 || $statusCode >= 300) {
            throw new \RuntimeException("Failed to fetch JWKS ({$statusCode})");
        }

        $jwks = json_decode($body, true);

        // Select the signing key by its JWK properties (use=sig, alg=EdDSA)
        $signingKey = null;
        foreach ($jwks['keys'] as $k) {
            if (($k['use'] ?? null) === 'sig' && ($k['alg'] ?? null) === 'EdDSA') {
                $signingKey = $k;
                break;
            }
        }

        if ($signingKey === null) {
            throw new \RuntimeException('No EdDSA signing key found in JWKS');
        }

        $publicKey = $this->base64UrlDecode($signingKey['x']);

        return $this->verifyAndDecode($sessionToken, $publicKey);
    }

    private function verifyAndDecode(string $token, string $publicKey): array
    {
        $parts = explode('.', $token);
        if (count($parts) !== 3) {
            throw new \InvalidArgumentException('Invalid JWT format. Expected 3 dot-separated parts.');
        }

        [$header, $payload, $signature] = $parts;

        $signedData = "{$header}.{$payload}";
        $signatureBytes = $this->base64UrlDecode($signature);

        if (!sodium_crypto_sign_verify_detached($signatureBytes, $signedData, $publicKey)) {
            throw new \RuntimeException('Invalid token signature.');
        }

        $decodedPayload = json_decode($this->base64UrlDecode($payload), true);

        if (($decodedPayload['exp'] ?? 0) < time()) {
            throw new \RuntimeException('Token has expired.');
        }

        return [
            'exp' => $decodedPayload['exp'],
            'userId' => $decodedPayload['userId'],
            'tenantId' => $decodedPayload['tenantId'],
            'tenantSlug' => $decodedPayload['tenantSlug'] ?? null,
        ];
    }

    private function base64UrlDecode(string $input): string
    {
        $padded = strtr($input, '-_', '+/');
        $remainder = strlen($padded) % 4;
        if ($remainder > 0) {
            $padded .= str_repeat('=', 4 - $remainder);
        }
        return base64_decode($padded);
    }
}
```

The class fetches the JWKS document, selects the EdDSA signing key, and verifies the session token's signature using `sodium_crypto_sign_verify_detached`. If the signature is valid and the token isn't expired, the method returns the decoded payload as an associative array.

## 6. Build the Connect Tenant Endpoint

Now connect the verifier into a Slim route. The frontend's shell layout sends the session token to `/api/connect-tenant` via the `X-Session-ID` header.

Create `backend/public/index.php`:

```php theme={null}
<?php

use App\JtlAuth;
use App\SessionVerifier;
use Dotenv\Dotenv;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Factory\AppFactory;
use GuzzleHttp\Client;

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

Dotenv::createImmutable(__DIR__ . '/..')->load();

$httpClient = new Client();
$auth = new JtlAuth($_ENV['CLIENT_ID'] ?? '', $_ENV['CLIENT_SECRET'] ?? '', $httpClient);
$verifier = new SessionVerifier($auth, $httpClient);

$app = AppFactory::create();
$app->addBodyParsingMiddleware();

// Allow the Vite dev server to call the API during development
$app->add(function (Request $request, $handler) {
    $response = $handler->handle($request);
    return $response
        ->withHeader('Access-Control-Allow-Origin', 'http://localhost:5173')
        ->withHeader('Access-Control-Allow-Headers', 'X-Session-ID, Authorization, Content-Type')
        ->withHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
});

$app->options('/{routes:.+}', fn(Request $req, Response $res) => $res);

$app->get('/api/connect-tenant', function (Request $request, Response $response) use ($verifier) {
    $sessionToken = $request->getHeaderLine('X-Session-ID');

    if (empty($sessionToken)) {
        $response->getBody()->write(json_encode(['error' => 'Session token is required']));
        return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
    }

    try {
        $payload = $verifier->verify($sessionToken);

        // In a production app, you'd store the tenant connection in your database here
        error_log("Tenant connected: {$payload['tenantId']}");

        $response->getBody()->write(json_encode([
            'success' => true,
            'tenantId' => $payload['tenantId'],
            'userId' => $payload['userId'],
            'tenantSlug' => $payload['tenantSlug'],
        ]));
        return $response->withHeader('Content-Type', 'application/json');
    } catch (\Throwable $e) {
        error_log("Connection failed: {$e->getMessage()}");
        $response->getBody()->write(json_encode(['error' => 'Failed to verify session token']));
        return $response->withStatus(401)->withHeader('Content-Type', 'application/json');
    }
});

$app->run();
```

The bootstrap loads `.env`, instantiates `JtlAuth` with the credentials, and passes that into `SessionVerifier`. Both instances are then captured by the route closure via `use ($verifier)` and shared across requests.

The CORS middleware allows requests from the Vite dev server on port 5173. The dev proxy in `vite.config.ts` already routes frontend `fetch('/api/...')` calls to this backend, but the CORS headers are a useful safety net during development.

See the [Tenant Mapping](/guides/cloud-apps/authentication-login#tenant-mapping) section for more on managing tenants in production.

## 7. Configure Composer Autoloading

Open `backend/composer.json` and add a `psr-4` autoload mapping for the `App\` namespace:

```json theme={null}
{
    "name": "my-jtl-app/backend",
    "type": "project",
    "require": {
        "slim/slim": "^4.15",
        "slim/psr7": "^1.7",
        "guzzlehttp/guzzle": "^7.9",
        "vlucas/phpdotenv": "^5.6"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    }
}
```

Then regenerate the autoloader so Composer picks up the new mapping:

```bash theme={null}
composer dump-autoload
```

This tells Composer that any class in the `App\` namespace lives under the `src/` directory, which is how `JtlAuth` and `SessionVerifier` get loaded when `index.php` references them.

## 8. Run the Backend

Start the dev server:

```bash theme={null}
php -S localhost:5273 -t public
```

You should see:

```
PHP 8.x.x Development Server (http://localhost:5273) started
```

In a second terminal, send a request with a fake session token to confirm the route is reachable:

```bash theme={null}
curl http://localhost:5273/api/connect-tenant \
  -H "X-Session-ID: fake-token"
```

You should get back a `401` response with `{"error":"Failed to verify session token"}`. That's the expected outcome for an invalid token. A real session token from the App Shell will follow the same path and succeed.

## Common Issues

<AccordionGroup>
  <Accordion title="'Class App\JtlAuth not found' or similar autoload error">
    This error means Composer's autoloader doesn't know where to find the class. The most common cause is forgetting to regenerate the autoload files after adding the `psr-4` mapping to `composer.json`.

    Running `composer dump-autoload` from the `backend/` directory rebuilds `vendor/autoload.php` to include any new namespace mappings. The error should clear after the next request.
  </Accordion>

  <Accordion title="'CLIENT_ID and CLIENT_SECRET must be defined in .env'">
    This error means the backend started but couldn't find your credentials in the environment. The most common cause is that `.env` is sitting in the wrong directory, or that the `Dotenv::createImmutable()` call is pointing at the wrong path.
  </Accordion>

  <Accordion title="'sodium_crypto_sign_verify_detached() does not exist'">
    This error means PHP's sodium extension isn't enabled. The extension ships with PHP 7.2 and later and is enabled by default on most distributions, but some minimal PHP installations (particularly Docker images and shared hosts) ship without it.

    Running `php -m | grep sodium` will confirm whether the extension is loaded. If the command returns nothing, the extension needs to be enabled.
  </Accordion>

  <Accordion title="'Failed to fetch JWT (401)' from the auth endpoint">
    A 401 from the auth endpoint means the credentials are not valid.

    If you are still using placeholder values, this is expected. Real credentials are provided after registering your app in the Partner Portal.

    If you have already registered:

    * check for typos or extra spaces in `.env`
    * restart the dev server after making changes

    Environment variables are only read at startup, so updates to `.env` require a restart.
  </Accordion>

  <Accordion title="CORS error in the browser console">
    A CORS error usually means the request reached the backend but the browser blocked the response because the origin didn't match what the backend allows. The CORS middleware in `index.php` is configured to allow `http://localhost:5173`, which matches the default Vite dev server port.

    If the Vite dev server is running on a different port (for example, because port 5173 was already in use and Vite picked 5174 instead), the browser will see a mismatch. Updating the `Access-Control-Allow-Origin` value in `index.php` to match the actual Vite port, or freeing up port 5173, should resolve it.
  </Accordion>
</AccordionGroup>

## Next: Connect and Fetch Data

The backend verifies session tokens and is ready to call the JTL Cloud API. The remaining work is registering your app with JTL to get real credentials, installing the app in the JTL Hub, and pulling product data from JTL-Wawi:

<CardGroup cols={2}>
  <Card title="Connect and Fetch Data" icon="link" href="/get-started/quick-start/from-scratch/connect-and-fetch">
    Register your app, install it in the JTL Hub, and fetch products from
    JTL-Wawi.
  </Card>
</CardGroup>
