Laravel
This guide covers a language integration with Laravel using theHttp 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, inconfig/services.php:
// config/services.php
'flash_md' => [
'url' => env('FLASH_MD_API_URL', 'https://api.markpdf.tech'),
'key' => env('FLASH_MD_API_KEY'),
],
# .env
FLASH_MD_API_URL=https://api.markpdf.tech
FLASH_MD_API_KEY=tu_key
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.Domain exception
A separate exception allows you to map API codes to HTTP responses that are consistent throughout the app, instead of repeatingif/else for each status.
<?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
// 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
// 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.');
}
}
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
// 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
// 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()]);
}
}
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.Rutas
// 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
// Large documents may take time. Separate connection timeout (fast)
// of the total timeout of the request (generous).
Http::timeout(300)->connectTimeout(10)->post(...);
// 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(...);
// 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();
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.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.