cd /news/developer-tools/how-to-parse-lots-of-pdfs-and-more-i… · home topics developer-tools article
[ARTICLE · art-58518] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

How to parse lots of PDFs and more into markdown, with Laravel

A developer built a Laravel 13 pipeline to parse uploaded PDFs and other documents into Markdown, chunk them, embed them into a pgvector column, and answer questions via an AI agent. The solution uses Postgres exclusively, avoiding a separate vector database, and integrates with a hosted parsing API for handling scanned PDFs and various file types.

read5 min views1 publishedJul 14, 2026

You want to ask questions about your own documents from a Laravel app. The AI SDK that shipped with Laravel 13 handles most of it: embeddings, vector queries, agents. What it doesn't handle is the part that comes first, where a user hands you a 40-page scanned PDF and you need clean text out of it.

That's the step this tutorial covers properly. The rest of the pipeline is here too, end to end: upload, parse, chunk, embed, search, answer.

Users upload documents. We convert each one to Markdown, split it into chunks, embed the chunks into a pgvector column, and answer questions with an agent that searches those chunks. Postgres only, no separate vector database.

You'll need Laravel 13, Postgres with the pgvector extension available, and two packages:

composer require laravel/ai parseforartisans/laravel

Full disclosure: parseforartisans/laravel is my product, Parse for Artisans. It's a hosted parsing API with a Laravel-native SDK. If all your documents are digital PDFs with a real text layer, you can swap that piece for spatie/pdf-to-text and pay nothing; I'll point out where. The hosted API earns its keep when uploads are scans, .doc files, emails, or all of the above, which in my experience is what "users upload documents" actually means.

Two tables: the document, and its chunks with an embedding each.

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
Schema::ensureVectorExtensionExists();
Schema::create('documents', function (Blueprint $table) {
    $table->id();
    $table->string('source_path');
    $table->string('status')->default('pending');
    $table->timestamps();
});
Schema::create('chunks', function (Blueprint $table) {
    $table->id();
    $table->foreignId('document_id')->constrained()->cascadeOnDelete();
    $table->text('content');
    $table->vector('embedding', dimensions: 1536)->index();
    $table->timestamps();
});

The models are small. Chunk casts the vector to an array, and Document gets the relation the listener will use:

class Chunk extends Model
{
    protected $guarded = [];
    protected function casts(): array

{
        return ['embedding' => 'array'];
    }
}
class Document extends Model
{
    protected $guarded = [];
    public function chunks(): HasMany

{
        return $this->hasMany(Chunk::class);
    }
}

Store the upload on your disk, then point the parser at the stored path. The SDK resolves paths like Storage does, so there's no temp-file juggling:

use App\Models\Document;
use Illuminate\Http\Request;
use ParseForArtisans\Facades\Parse;
public function store(Request $request)
{
    $request->validate([
        'document' => ['required', 'file', 'mimes:pdf,doc,docx,xlsx,pptx,msg,eml', 'max:51200'],
    ]);
    $path = $request->file('document')->store('uploads', 's3');
    $document = Document::create(['source_path' => $path]);
    Parse::disk('s3')->file($path)->for($document)->parse();
    return back()->with('status', 'Processing.');
}

Parsing is async. ->for($document) ties the job to your model, so when the result comes back you get your record handed to you instead of matching ids. Scanned PDFs are OCR'd automatically; you don't tell it the file type.

The spatie alternative: replace the Parse:: line with Pdf::getText(Storage::disk('s3')->path($path)) and do the chunking inline. Works well until the first scan or .docx shows up, because pdftotext reads text layers and nothing else.

The Markdown arrives in a ParseCompleted event. Listen for it, split the text, and embed all chunks in one call:

use Laravel\Ai\Embeddings;
use ParseForArtisans\Events\ParseCompleted;
public function handle(ParseCompleted $event): void
{
    $document = $event->request->parsable;   // your Document model, typed
    $markdown = $event->request->markdown();
    $chunks = $this->split($markdown);
    $embeddings = Embeddings::for($chunks)->generate()->embeddings;
    foreach ($chunks as $i => $content) {
        $document->chunks()->create([
            'content' => $content,
            'embedding' => $embeddings[$i],
        ]);
    }
    $document->update(['status' => 'ready']);
}

For splitting, start dumb. Markdown gives you natural seams, so split on headings, then cap the size:

/** @return string[] */
private function split(string $markdown, int $maxLength = 2000): array
{
    $sections = preg_split('/^(?=#{1,3} )/m', $markdown);
    return collect($sections)
        ->flatMap(fn ($s) => str_split(trim($s), $maxLength))
        ->filter(fn ($s) => strlen($s) > 50)
        ->values()
        ->all();
}

This is the crudest chunker that works, and clean Markdown input is what makes it viable. When your parser preserves headings and table structure, heading-based splits keep related content together. When your input is the word soup that OCR tools produce, no chunking strategy saves you. Garbage in, garbage embedded.

The AI SDK's agents can search your chunks with the built-in similarity search tool. Make an agent:

php artisan make:agent DocumentAssistant

namespace App\Ai\Agents;
use App\Models\Chunk;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Promptable;
use Laravel\Ai\Tools\SimilaritySearch;
class DocumentAssistant implements Agent, HasTools
{
    use Promptable;
    public function instructions(): string

{
        return 'Answer using the document excerpts you retrieve. '
            .'If the excerpts do not contain the answer, say so.';
    }
    public function tools(): iterable

{
        return [
            SimilaritySearch::usingModel(Chunk::class, 'embedding', limit: 8),
        ];
    }
}

$response = (new DocumentAssistant)->prompt(
    'What termination notice period does the supplier contract require?'
);
echo $response;

The agent embeds the question, pulls the nearest chunks, and answers from them. If you'd rather control retrieval yourself, the query builder does it directly:

$chunks = Chunk::query()
    ->whereVectorSimilarTo('embedding', 'termination notice period')
    ->limit(8)
    ->get();

Passing a string makes the SDK generate the query embedding for you.

Ten thousand files work the same way as one. Parse::files($paths) submits a batch, and each file fires its own ParseCompleted event, so the listener you already wrote is the whole pipeline. Queue the listener (implements ShouldQueue) so embedding runs off the request path.

One alternative before you build any of this: if you don't want to own chunking and a vector column at all, the AI SDK also supports provider vector stores. Stores::create(), add files, and give an agent the FileSearch tool. You give up control over chunking and your data lives with the provider, but it's less code. I like owning the Postgres side; on a quick prototype I'd take the store.

That's the pipeline: a controller, a listener, an agent. The reason it stays this short is that every step downstream of parsing gets to assume clean Markdown. That assumption is the entire game in document RAG, and it's decided at the step most tutorials skip.

Parse for Artisans is a document parsing API for Laravel that converts PDF, DOCX, scans, and 20+ formats to clean Markdown. There's a free tier of 15,000 pages a month.

── more in #developer-tools 4 stories · sorted by recency
── more on @laravel 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-to-parse-lots-of…] indexed:0 read:5min 2026-07-14 ·