> ## Documentation Index
> Fetch the complete documentation index at: https://docs.markpdf.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# Laravel

> Complete integration in Laravel: controller, Form Request, error handling, Job queue and production.

# Laravel

This guide covers a language integration with Laravel using the `Http` facade (based on Guzzle), including Form Request vavalidation, a domain exception of its own, `/jobs/{job_id}` polling for the `202` flow, a Job queue for large files, and production recommendations (timeouts, retries, and BYOS exit).

## Config

Register the URL base and API key like any other external service, in `config/services.php`:

```php theme={null}
// config/services.php
'flash_md' => [
    'url' => env('FLASH_MD_API_URL', 'https://api.markpdf.tech'),
    'key' => env('FLASH_MD_API_KEY'),
],
```

```bash theme={null}
# .env
FLASH_MD_API_URL=https://api.markpdf.tech
FLASH_MD_API_KEY=tu_key
```

<Warning>
  Do not hardcode the API key in the controller or upload it to the repo. Always use `config('services.flash_md.key')`, which reads from `.env`.
</Warning>

## Domain exception

A separate exception allows you to map API codes to HTTP responses that are consistent throughout the app, instead of repeating `if/else` for each status.

```php theme={null}
<?php
// app/Exceptions/FlashMdException.php
namespace App\Exceptions;

use Exception;

class FlashMdException extends Exception
{
    public function __construct(
        public readonly int $status,
        public readonly string $detail,
    ) {
        parent::__construct("Flash PDF to Markdown API error ({$status}): {$detail}");
    }

    /**
     * Translate the status of the API upstream into something reasonable to return to the client.
     * 4xx (except 429) are propagated as is because they are errors in the request;
     * 429/5xx translate to 503 because they are transient and the client must retry.
     */
    public function httpStatus(): int
    {
        return match (true) {
            $this->status === 401, $this->status === 403 => 502, // no exponemos el detalle de auth interna
            $this->status === 429 => 503,
            $this->status >= 500 => 503,
            default => $this->status,
        };
    }

    public function render()
    {
        return response()->json(['detail' => $this->detail], $this->httpStatus());
    }
}
```

## Form Request

Vavalidate the uploaded file and optional parameters before touching the API.

```php theme={null}
<?php
// app/Http/Requests/ConvertRequest.php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class ConvertRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'file' => ['required', 'file', 'max:51200'], // 50 MB, ajusta a tus lílimits reales
            'mode' => ['sometimes', 'in:fast,ultra_fast,balanced,quality,auto'],
            'clean' => ['sometimes', 'boolean'],
        ];
    }

    public function messages(): array
    {
        return [
            'file.required' => 'You must attach a document.',
            'file.max' => 'The document exceeds the maximum allowed size.',
        ];
    }
}
```

## Cliente reutilizable

Encapsulates the calls to API in their own class to avoid repeating headers, timeouts or job polling in each controller.

```php theme={null}
<?php
// app/Services/FlashMdClient.php
namespace App\Services;

use App\Exceptions\FlashMdException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class FlashMdClient
{
    private string $apiUrl;
    private string $apiKey;

    public function __construct()
    {
        $this->apiUrl = rtrim(config('services.flash_md.url'), '/');
        $this->apiKey = config('services.flash_md.key');
    }

    private function client(): PendingRequest
    {
        return Http::withHeaders(['x-api-key' => $this->apiKey])
            ->timeout(300)          // large PDF conversion can take time
            ->connectTimeout(10)
            ->retry(3, 1000, function ($exception, $request) {
                // only retry transient errors (timeouts, 429, 5xx); never 4xx from customer
                if (method_exists($exception, 'response') && $exception->response) {
                    $status = $exception->response->status();
                    return $status === 429 || $status >= 500;
                }
                return true; // ConnectionException: red caída, DNS, etc.
            }, throw: false);
    }

    /**
     * Upload a binary file to /convert/raw and if the backend is saturated (202),
     * automatically polls /jobs/{id} until finished.
     */
    public function convertRaw(string $filename, string $contents, string $contentType, array $params = []): string
    {
        $query = http_build_query(array_merge(['filename' => $filename, 'mode' => 'fast'], $params));

        $response = $this->client()
            ->withBody($contents, $contentType)
            ->post("{$this->apiUrl}/convert/raw?{$query}");

        return $this->resolve($response);
    }

    /**
     * Converts from an already accessible URL (for example, an object in S3).
     */
    public function convertFromUrl(string $url, string $filename, array $params = []): string
    {
        $response = $this->client()
            ->asJson()
            ->post("{$this->apiUrl}/convert/from-url", array_merge([
                'url' => $url,
                'filename' => $filename,
                'mode' => 'fast',
            ], $params));

        return $this->resolve($response);
    }

    private function resolve($response): string
    {
        if ($response->successful()) {
            return $response->body();
        }

        if ($response->status() === 202) {
            $job = $response->json();
            return $this->pollJob($job['job_id']);
        }

        $detail = $response->json('detail') ?? $response->body();
        Log::warning('flash_md.error', ['status' => $response->status(), 'detail' => $detail]);
        throw new FlashMdException($response->status(), $detail);
    }

    public function pollJob(string $jobId, int $maxAttempts = 60): string
    {
        for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
            sleep(5);

            $res = Http::withHeaders(['x-api-key' => $this->apiKey])
                ->timeout(30)
                ->get("{$this->apiUrl}/jobs/{$jobId}");

            if (!$res->successful()) {
                throw new FlashMdException($res->status(), $res->json('detail') ?? 'Error consultando el job.');
            }

            $data = $res->json();

            if ($data['status'] === 'completed') {
                return $data['body'];
            }
            if ($data['status'] === 'failed') {
                throw new FlashMdException(500, $data['error'] ?? 'El job falló.');
            }
            // 'queued' o 'processing': seguimos esperando
        }

        throw new FlashMdException(504, 'Timeout esperando a que el job termine.');
    }
}
```

Register it as a singleton in `AppServiceProvider` if you prefer to inject it per interface, or simply resolve it by autowiring — Laravel instantiates it automatically when requested in a controller constructor.

## Controller

```php theme={null}
<?php
// app/Http/Controllers/ConvertController.php
namespace App\Http\Controllers;

use App\Exceptions\FlashMdException;
use App\Http\Requests\ConvertRequest;
use App\Jobs\ConvertLargeDocument;
use App\Services\FlashMdClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;

class ConvertController extends Controller
{
    public function __construct(private FlashMdClient $flashMd)
    {
    }

    /**
     * Synchronous conversion: suitable for small/medium files where
     * the user waits for the response in the same request.
     */
    public function convert(ConvertRequest $request): Response|JsonResponse
    {
        $file = $request->file('file');


        try {
            $markdown = $this->flashMd->convertRaw(
                $file->getClientOriginalName(),
                $file->getContent(),
                $file->getMimeType() ?: 'application/octet-stream',
                $params,
            );
        } catch (FlashMdException $e) {
            return $e->render();
        }

        return response($markdown, 200)
            ->header('Content-Type', 'text/markdown; charset=utf-8');
    }

    /**
     * For large files: queue the job and respond 202 immediately.
     * The result is saved wherever your Job decides (storage, DB, user notification).
     */
    public function convertAsync(ConvertRequest $request): JsonResponse
    {
        $file = $request->file('file');
        $path = $file->store('uploads/pending');

        ConvertLargeDocument::dispatch(
            $path,
            $file->getClientOriginalName(),
            $file->getMimeType() ?: 'application/octet-stream',
            $request->user()?->id,
        );

        return response()->json(['status' => 'queued'], 202);
    }
}
```

## Job queued for large files

For large documents it is advisable not to block the HTTP request: the file is uploaded, a Job is dispatched and the conversion (including the possible polling of `/jobs/{id}` on the API side%) runs in the background.

```php theme={null}
<?php
// app/Jobs/ConvertLargeDocument.php
namespace App\Jobs;

use App\Exceptions\FlashMdException;
use App\Services\FlashMdClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;

class ConvertLargeDocument implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $timeout = 900; // 15 min: the API can take time for heavy documents

    public function __construct(
        private string $storedPath,
        private string $filename,
        private string $contentType,
        private array $params,
        private ?int $userId,
    ) {
    }

    public function handle(FlashMdClient $flashMd): void
    {
        $contents = Storage::get($this->storedPath);

        try {
            $markdown = $flashMd->convertRaw($this->filename, $contents, $this->contentType, $this->params);

            Storage::put("conversions/{$this->filename}.md", $markdown);

            // Notify the user, update a record in DB, fire an event, etc.
            Log::info('flash_md.job.completed', ['filename' => $this->filename, 'user_id' => $this->userId]);
        } catch (FlashMdException $e) {
            Log::error('flash_md.job.failed', ['filename' => $this->filename, 'detail' => $e->detail]);
            throw $e; // deja que el mecanismo de reintentos de la cola actúe
        } finally {
            Storage::delete($this->storedPath);
        }
    }

    public function failed(\Throwable $e): void
    {
        Log::error('flash_md.job.exhausted', ['filename' => $this->filename, 'error' => $e->getMessage()]);
    }
}
```

<Note>
  `ConvertLargeDocument` already delegates handling of `202`/polling to `FlashMdClient::convertRaw()`, so the Job doesn't need its own polling logic — it just handles its own queue retries if the entire call fails.
</Note>

## Rutas

```php theme={null}
// routes/api.php
use App\Http\Controllers\ConvertController;

Route::middleware('auth:sanctum')->group(function () {
    Route::post('/convert', [ConvertController::class, 'convert']);
    Route::post('/convert/async', [ConvertController::class, 'convertAsync']);
});
```

## Production

<CodeGroup>
  ```php Timeouts theme={null}
  // Large documents may take time. Separate connection timeout (fast)
  // of the total timeout of the request (generous).
  Http::timeout(300)->connectTimeout(10)->post(...);
  ```

  ```php Reintentos con backoff theme={null}
  // Http::retry(veces, milisegundos_base, callback_condicional)
  // The callback decides whether it is worth retrying based on the type of error.
  Http::retry(3, 1000, function ($exception, $request) {
      if (method_exists($exception, 'response') && $exception->response) {
          $status = $exception->response->status();
          return $status === 429 || $status >= 500; // nunca reintentes 4xx "normales"
      }
      return true;
  })->post(...);
  ```

  ```php BYOS con S3 (output_url) theme={null}
  // For large documents avoid bringing the Markdown through the body: ask the API
  // to upload it directly to your bucket with a pre-signed URL PUT.
  use Illuminate\Support\Facades\Storage;

  $key = 'conversions/' . uniqid() . '.md.zst';

  $putUrl = Storage::disk('s3')->temporaryUrl(
      $key,
      now()->addMinutes(15),
      ['method' => 'PUT'],
  );

  $headUrl = Storage::disk('s3')->temporaryUrl(
      $key,
      now()->addMinutes(15),
      ['method' => 'HEAD'],
  );

  $response = Http::withHeaders(['x-api-key' => config('services.flash_md.key')])
      ->timeout(300)
      ->asJson()
      ->post(config('services.flash_md.url') . '/convert/from-url', [
          'url' => $sourceUrl,
          'filename' => 'report.pdf',
          'output_url' => $putUrl,
          'output_head_url' => $headUrl,
          // output_url auto-activa response_format=json, output_encoding=zstd, slim=true
      ]);

  // The response is a small JSON with metadata; the Markdown is already in S3 under $key.
  $meta = $response->json();
  ```
</CodeGroup>

<Tip>
  Combine `output_url` with `output_head_url`: if the object already exists in your bucket (same content hash), API returns the cached result without reprocessing the document — useful when retrying a Job that had already completed the upload but later failed.
</Tip>

<Warning>
  Don't blindly use `retry()` on `POST /convert` with `input_format` or invavalid parameters: those are `400`/`422` errors that won't go away by retrying. The `retry()` callback in the example above already filters this out — it only retries `429` and `5xx`.
</Warning>
