Optimizing PPT Generation for 1000+ Slide Photo Books in Laravel
How I cut generation time from minutes to seconds by replacing PHPPresentation with ZipArchive + XML templates, parallel image downloads, and smarter zip compression.
Generating a 1,200-slide photo book used to take over 5 minutes and eat 2GB of RAM. Here's how I got it down to under 30 seconds.
The Problem
We had a Laravel app that exported survey responses as PowerPoint presentations. Essentially photo books where each slide showed an image plus metadata: question, user, point of sale, timestamp. With PHPPresentation (PHPOffice), a 1,000-slide export meant waiting several minutes and watching memory climb to 2GB. It was not sustainable.
Why PHPPresentation Struggles at Scale
PHPPresentation builds slides through a high-level API. Every slide, every shape, every image goes through object creation, internal state management, and eventual XML serialization. For a few dozen slides, that's fine. For hundreds or thousands, the overhead adds up. Slow loops, high memory usage, and no easy way to tweak the output. The library keeps the whole presentation in memory until you save. With 1,000+ slides, that model does not scale.
I needed a different approach. A PPTX file is just a ZIP archive full of XML and media. What if I worked at that level instead?
Template + XML Instead of Object Trees
PowerPoint files are OOXML packages. Inside the ZIP you have presentation.xml, slide1.xml, slide2.xml, and so on, plus media in ppt/media. Instead of creating everything from scratch, I started with a template that had just two slide models: one for category separators and one for content slides.
The flow was simple. Extract the template into a temp directory. Copy the right model slide for each position. Replace placeholders in the slide XML with the actual data (e.g. the question text or the user name). Update the slide's relationship file so it points to the correct image. That's it. No PHPPresentation objects. Just file copies and string replacement. You need to escape values before plugging them into the XML, but that's straightforward. The template is generated once and reused.
A minimal idea of the placeholder replacement:
1// Simplified: replace placeholders in the slide XML2$content = file_get_contents($slidePath);3$content = strtr($content, [4 '{{QUESTION}}' => $data->question,5 '{{USER}}' => $data->user,6 // ... other fields7]);8file_put_contents($slidePath, $content);9Download Images in Parallel First
Images lived in Google Cloud Storage. The naive approach: download each image when building its slide. That meant hundreds of sequential HTTP requests.
I flipped it. Before the slide loop, I downloaded all images in parallel. A pool of concurrent requests (Guzzle works well for this). Signed URLs from GCS, configurable concurrency. By the time I iterated over slides, every image was already on disk. The slide loop became pure XML and file operations. No network I/O in the hot path. If a download failed, I surfaced which slide broke and stopped.
Smarter Zip Compression
Images are already compressed (JPEG, PNG). Re-compressing them inside the PPTX ZIP is wasteful. CPU time for no size gain. I wanted to store them without compression.
When using the system zip command, the trick is the store flag. No compression, just pack the bytes:
1zip -0 -r output.pptx .2The -0 means "store." Fast and simple. For the PHP fallback, I iterated over files and set compression to "store" only for image entries in ppt/media. XML stayed compressed normally.
Prefer Native Zip When Available
The system zip command is often faster than PHP's ZipArchive for large directories. I tried that first. If zip was not installed or the command failed, I fell back to PHP. No changes to the rest of the pipeline, just a faster final step when possible.
Batch All OOXML Updates
Adding slides means updating several files: presentation.xml.rels, presentation.xml, Content_Types, docProps. Doing that incrementally per slide would be messy. IDs and relationships need to stay in sync.
I accumulated slide registrations during the loop and did a single flush at the end. Rebuild relationships, slide order, content types, document properties. All in one pass. Keeps the hot path simple and predictable.
Results
Time (s)
Memory (MB)
On a typical 1,000-slide job, the new pipeline completes in under 30 seconds with a fraction of the previous memory usage. Before: 5+ minutes, around 2GB RAM. After: under 30 seconds, memory stays low because we never hold the full presentation as an object graph. Just ZIP, XML, and parallel downloads.
A benchmark command helps. Run a full export and it reports timings per step: extract, slide loop, flush, zip. If the slide loop dominates, the bottleneck is likely in the per-slide work. If zip dominates, the final packaging is the limit.
Takeaways
- PPTX is a ZIP. Working at the OOXML level gives you control and performance.
- Download images in parallel before building slides. Keep the main loop network-free.
- Don't compress media that's already compressed. Use store mode for images in the final ZIP.
- Prefer the system zip when available. Fall back to PHP only when needed.
- Batch all OOXML updates in a single flush so the pipeline stays clean.
If you're building similar export pipelines in Laravel, I'm happy to discuss. Reach out on X or GitHub.