Exporting a Gantt chart is harder than it should be
The Starting Point
The platform has a project management module with a task list (collapsible tree of tasks and subtasks) and a DHTMLX Gantt chart view. The ask was simple: let users export the task list to Excel, and export the Gantt chart as PDF and PNG.
I also squeezed in a quick admin feature — a deployment history page that reads from git log and shows recent commits, plus a commit hash in the footer so anyone can tell which version they're looking at.
What Actually Happened
The deployment page (the easy win)
This went smoothly. Created a controller that shells out to git log --pretty=format via Laravel's Process facade, parses the pipe-delimited output, and passes it to a Blade view styled to match the existing admin layout. Added a short commit hash to the footer on every page, cached for an hour so it's not running git on every request.
No drama. Shipped in one go.
Excel export: PhpSpreadsheet's API surprise
The platform already had PhpSpreadsheet v5 installed (used for CSV/XLSX imports elsewhere). I wrote the export method using what I remembered from older projects:
$sheet->getCellByColumnAndRow($col + 1, 1)->setValue($header);
Hit the page and got:
Call to undefined method
PhpOffice\PhpSpreadsheet\Worksheet\Worksheet::getCellByColumnAndRow()
Turns out getCellByColumnAndRow() was removed in PhpSpreadsheet v2+. The entire method just doesn't exist anymore. The new API uses string cell references:
// Old (removed)
$sheet->getCellByColumnAndRow($col + 1, $row);
// New
$sheet->setCellValue('A' . $row, $value);
I had to define a column letter array and index into it:
$columns = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'];
foreach ($data as $i => $value) {
$sheet->setCellValue($columns[$i] . $row, $value);
}
Not hard to fix, but annoying that the old tutorials and Stack Overflow answers all show the removed method without any deprecation notice.
Gantt export: the viewport trap
The Gantt chart uses DHTMLX (community edition, loaded from local vendor files — no npm on production). The community edition doesn't include any export functionality, so I went with client-side capture using html2canvas + jsPDF, the same approach used elsewhere in the app for bill template exports.
First attempt worked... technically. But it only captured what was visible in the browser viewport. The Gantt container is set to height: 500px with internal scrolling, so any tasks below the fold were cut off. Collapsed task branches were hidden too.
The fix needed three things before capturing:
-
Expand all branches — DHTMLX has
gantt.eachTask()which lets you set$open = trueon every task, then re-render. -
Resize the container — Calculate the full height from the visible task count times the row height, set it on the container, and re-render again so the Gantt fills the space without scrolling.
-
Restore after capture — Put the height back to 500px so the page isn't broken after exporting.
function prepareGanttForExport() {
gantt.eachTask(function(task) {
task.$open = true;
});
gantt.render();
var el = document.getElementById('gantt-chart');
var originalHeight = el.style.height;
var totalRows = gantt.getVisibleTaskCount();
var rowHeight = gantt.config.row_height || 36;
var fullHeight = (totalRows * rowHeight) + 60;
el.style.height = fullHeight + 'px';
gantt.render();
return { el: el, originalHeight: originalHeight };
}
The key insight was that gantt.render() needs to be called twice — once after opening branches (so getVisibleTaskCount() returns the correct number), and again after resizing (so the Gantt actually draws into the new space).
Also had to pass scrollY: 0, scrollX: 0 to html2canvas to prevent it from offsetting the capture based on the page scroll position.
What Worked
Three exports shipped:
-
Excel — server-side PhpSpreadsheet, downloads an
.xlsxwith styled headers, subtasks indented and greyed out. The task hierarchy is preserved with parent tasks in bold black and subtasks in grey. -
PDF — client-side html2canvas + jsPDF. The page dimensions are set to match the canvas exactly (no cropping, no A4 fitting), and orientation flips based on aspect ratio.
-
PNG — same capture as PDF, just downloads the canvas directly at 2x resolution.
Both Gantt exports show the full chart with all branches expanded, regardless of what was visible when the user clicked the button.
The deployment history page was a bonus — a simple admin page that reads git log, plus a commit hash in the footer of every page so you always know what version is deployed.
What I Learned
-
PhpSpreadsheet v2+ removed
getCellByColumnAndRow()entirely. No deprecation period that I noticed — it's just gone. If you're upgrading from v1.x or following old tutorials, you'll hit this immediately. UsesetCellValue('A1', $value)with string references instead. -
html2canvas captures what's rendered, not what exists. If your content is inside a scrollable container, the captured image only includes what fits in the visible area. You have to temporarily resize the container to its full content height before capturing.
-
DHTMLX Gantt needs double-render when preparing for export. Opening branches changes the task count, but
getVisibleTaskCount()only reflects the new count after arender(). Then resizing the container needs anotherrender()so the chart fills the new space. -
DHTMLX Community Edition has no export. The enterprise version includes PDF/PNG export, but if you're on the free tier you're doing it yourself with html2canvas or similar.
-
scrollY: 0matters for html2canvas. If the user has scrolled down the page, html2canvas can offset the capture. SettingscrollY: 0andscrollX: 0in the options fixes this. -
Caching the commit hash is worth it. Running
exec('git rev-parse --short HEAD')on every page load is wasteful. Wrapping it incache()->remember('key', 3600, ...)means it runs once per hour.
Tech Notes
- Stack: Laravel 12, PHP 8.4, PhpSpreadsheet v5, DHTMLX Gantt (community), html2canvas 1.4.1, jsPDF 2.5.1
- No new dependencies — PhpSpreadsheet and DomPDF were already in composer.json, html2canvas and jsPDF loaded from CDN
- No migrations — pure application logic
- Export libraries loaded lazily — html2canvas and jsPDF are only fetched from CDN when the user clicks an export button, not on page load
- The
Processfacade — Laravel's built-in wrapper for shell commands, cleaner thanexec()for the deployment history page
Zero Focus Dev
Breaking things, learning fast, sharing it all.