<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Michael Oteng</title>
    <description>The latest articles on DEV Community by Michael Oteng (@xetera1).</description>
    <link>https://dev.to/xetera1</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4121886%2Fcedc935e-7231-4384-bfe7-adc8dfd1c4dc.png</url>
      <title>DEV Community: Michael Oteng</title>
      <link>https://dev.to/xetera1</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/xetera1"/>
    <language>en</language>
    <item>
      <title>Testing Microsoft Publisher Conversion on Linux: What Worked, What Broke, and Why Fidelity Matters</title>
      <dc:creator>Michael Oteng</dc:creator>
      <pubDate>Sun, 01 Nov 2026 20:48:42 +0000</pubDate>
      <link>https://dev.to/xetera1/testing-microsoft-publisher-conversion-on-linux-what-worked-what-broke-and-why-fidelity-matters-3766</link>
      <guid>https://dev.to/xetera1/testing-microsoft-publisher-conversion-on-linux-what-worked-what-broke-and-why-fidelity-matters-3766</guid>
      <description>&lt;p&gt;Microsoft Publisher's retirement creates a practical problem for anyone maintaining older publications: what happens when you have &lt;code&gt;.pub&lt;/code&gt; files but no longer have access to the application that created them?&lt;/p&gt;

&lt;p&gt;Microsoft has recommended converting existing publications into other formats. PDF is an obvious choice when the objective is to preserve a document for viewing or distribution. But converting a file and preserving its original layout are not necessarily the same thing.&lt;/p&gt;

&lt;p&gt;I wanted to investigate that distinction while implementing Publisher conversion support for a web-based document utility platform.&lt;/p&gt;

&lt;p&gt;Rather than relying on documentation stating that a particular format was supported, I tested actual Publisher documents through several conversion routes.&lt;/p&gt;

&lt;p&gt;The investigation included:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LibreOffice 24.2 and 26.8 on Linux.&lt;/li&gt;
&lt;li&gt;Font compatibility testing.&lt;/li&gt;
&lt;li&gt;Scribus and its scripted import interface.&lt;/li&gt;
&lt;li&gt;Direct &lt;code&gt;libmspub&lt;/code&gt; utilities.&lt;/li&gt;
&lt;li&gt;Editable OpenDocument Drawing (ODG) output.&lt;/li&gt;
&lt;li&gt;Direct DOCX export.&lt;/li&gt;
&lt;li&gt;PDF-to-PNG rendering using Poppler.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some conversions worked. Some failed immediately. Others generated perfectly valid output files that were visually unsuitable for their intended purpose.&lt;/p&gt;

&lt;p&gt;Those differences became the most useful part of the investigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The problem: what does a successful conversion actually mean?
&lt;/h2&gt;

&lt;p&gt;Microsoft Publisher was widely used for brochures, newsletters, flyers, invitations and other page-layout documents.&lt;/p&gt;

&lt;p&gt;Those documents can contain more than ordinary paragraphs. A publication may rely on independently positioned text boxes, layered images, unusual fonts, linked text containers and carefully arranged shapes.&lt;/p&gt;

&lt;p&gt;Converting that material into another format introduces several different questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File conversion:&lt;/strong&gt; Can the application read the source and generate an output file?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Visual fidelity:&lt;/strong&gt; Does the converted publication still resemble the original?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Practical editability:&lt;/strong&gt; Can someone open the result and meaningfully modify its contents?&lt;/p&gt;

&lt;p&gt;I decided to evaluate these separately.&lt;/p&gt;

&lt;p&gt;A command that exits successfully is evidence that a processing operation completed. It is not, by itself, evidence that the resulting document is visually correct.&lt;/p&gt;

&lt;p&gt;Likewise, a publication that looks reasonable as a PDF may not provide a useful way to update its original text, images or design.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The testing environment
&lt;/h2&gt;

&lt;p&gt;My initial conversion work was performed on an Ubuntu 24.04 server running LibreOffice 24.2.7.2.&lt;/p&gt;

&lt;p&gt;I subsequently built an isolated test environment using Docker Desktop on Windows, with LibreOffice 26.8.2.2 running inside a Linux container.&lt;/p&gt;

&lt;p&gt;The relevant software included:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Version or environment&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Linux server&lt;/td&gt;
&lt;td&gt;Ubuntu 24.04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Original LibreOffice&lt;/td&gt;
&lt;td&gt;24.2.7.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New LibreOffice&lt;/td&gt;
&lt;td&gt;26.8.2.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker test environment&lt;/td&gt;
&lt;td&gt;Docker Desktop 4.97.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker Engine&lt;/td&gt;
&lt;td&gt;29.9.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;libmspub tools&lt;/td&gt;
&lt;td&gt;0.1.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scribus&lt;/td&gt;
&lt;td&gt;1.6.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDF rendering&lt;/td&gt;
&lt;td&gt;Poppler &lt;code&gt;pdftoppm&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I used separate LibreOffice user profiles for the conversion tests to avoid interfering with an existing application profile.&lt;/p&gt;

&lt;p&gt;The test documents were three Publisher fixtures identified during the investigation as Apache POI samples:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Document&lt;/th&gt;
&lt;th&gt;Pages&lt;/th&gt;
&lt;th&gt;Characteristics&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Sample.pub&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Relatively simple sample&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SampleBrochure.pub&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Art gallery brochure with text boxes, images and decorative objects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SampleNewsletter.pub&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Text-heavy newsletter with multiple columns, headings and article sections&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These were useful because the files were not equally complex.&lt;/p&gt;

&lt;p&gt;The simple publication provided a basic compatibility test. The brochure and newsletter exposed problems with text positioning, overlapping content and page composition.&lt;/p&gt;

&lt;p&gt;This was a small exploratory test set, not a comprehensive compatibility benchmark across every Publisher version.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Baseline: converting Publisher to PDF
&lt;/h2&gt;

&lt;p&gt;The original Ubuntu installation reported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/usr/bin/libreoffice &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LibreOffice 24.2.7.2 420(Build:2)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Publisher documents were imported as LibreOffice Draw documents and exported using the &lt;code&gt;draw_pdf_Export&lt;/code&gt; filter.&lt;/p&gt;

&lt;p&gt;The application-side command construction used Symfony Process, including an isolated user profile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$process&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Process&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.libreoffice.path'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/usr/bin/libreoffice'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'-env:UserInstallation=file://'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$profileDirectory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'--headless'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'--nologo'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'--nodefault'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'--nofirststartwizard'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'--convert-to'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'pdf:draw_pdf_Export'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'--outdir'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;$outputDirectory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The results were mixed.&lt;/p&gt;

&lt;p&gt;The simple Publisher sample converted with an acceptable appearance.&lt;/p&gt;

&lt;p&gt;The brochure also converted, retaining two pages, text, images and much of its overall structure. However, the layout was visibly damaged.&lt;/p&gt;

&lt;p&gt;Text wrapped differently inside its containers. Some headings were incorrectly sized or positioned. Images and decorative elements overlapped text in places where the original publication had a more deliberate arrangement.&lt;/p&gt;

&lt;p&gt;The newsletter was worse.&lt;/p&gt;

&lt;p&gt;Although it produced a four-page PDF, several article sections had overlapping paragraphs. Content from different text regions appeared intermingled, and the intended multi-column layout was no longer reliably readable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The PDF conversion worked technically, but the complex documents were not preserved faithfully.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This was the first important result.&lt;/p&gt;

&lt;p&gt;A valid PDF can still be a poor migration outcome.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;[Figure 1: Original Publisher brochure and converted PDF, showing text-box and image positioning differences.]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;[Figure 2: Converted newsletter, showing overlapping article content and displaced text.]&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Could missing fonts explain the layout damage?
&lt;/h2&gt;

&lt;p&gt;Font substitution was one of the first possibilities I investigated.&lt;/p&gt;

&lt;p&gt;The original Linux environment did not have several Microsoft fonts installed.&lt;/p&gt;

&lt;p&gt;This matters because the dimensions of a text box do not necessarily change when its original font is unavailable. If the replacement font has different character widths, line heights or spacing, the text may wrap differently.&lt;/p&gt;

&lt;p&gt;A paragraph that originally fitted inside a fixed container may need an additional line after substitution.&lt;/p&gt;

&lt;p&gt;I installed the Microsoft core fonts using the Ubuntu &lt;code&gt;ttf-mscorefonts-installer&lt;/code&gt; package and verified font matching for fonts including Arial, Times New Roman, Trebuchet MS and Comic Sans MS.&lt;/p&gt;

&lt;p&gt;I then repeated the brochure conversion.&lt;/p&gt;

&lt;p&gt;There was a slight improvement, but the main layout defects remained.&lt;/p&gt;

&lt;p&gt;The text-box positioning and more substantial content overlaps were not resolved.&lt;/p&gt;

&lt;p&gt;This did not establish that fonts were irrelevant. They clearly affect document layout.&lt;/p&gt;

&lt;p&gt;It established something narrower: &lt;strong&gt;missing fonts were not sufficient to explain the conversion problems observed in this brochure.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The investigation therefore needed to move beyond font substitution.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Would a newer LibreOffice version improve the result?
&lt;/h2&gt;

&lt;p&gt;The next experiment was to test LibreOffice 26.8.2.2 independently of the production installation.&lt;/p&gt;

&lt;p&gt;I downloaded the official Linux Debian archive and built a Docker image containing the newer version.&lt;/p&gt;

&lt;p&gt;The container reported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;docker&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--rm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;swiftvecto-pub-lo268&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--version&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LibreOffice 26.8.2.2 9dea406ab7695df6325b11b0e19fdca8db4b7276
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I then converted the brochure and newsletter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;docker&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--rm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"type=bind,source=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Get-Location&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.Path),target=/work"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nx"&gt;swiftvecto-pub-lo268&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nx"&gt;UserInstallation&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;file:///tmp/lo-pub-profile&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--headless&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--convert-to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;pdf:draw_pdf_Export&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--outdir&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;/work/output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nx"&gt;/work/SampleBrochure.pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nx"&gt;/work/SampleNewsletter.pub&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command output showed two successful exports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;convert /work/SampleBrochure.pub as a Draw document -&amp;gt; /work/output/SampleBrochure.pdf using filter : draw_pdf_Export
convert /work/SampleNewsletter.pub as a Draw document -&amp;gt; /work/output/SampleNewsletter.pdf using filter : draw_pdf_Export
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I verified the generated files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-ChildItem&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;\output\&lt;/span&gt;&lt;span class="o"&gt;*.&lt;/span&gt;&lt;span class="nf"&gt;pdf&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Select-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Length&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Name                 Length
----                 ------
SampleBrochure.pdf   226390
SampleNewsletter.pdf 367071
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The files existed, contained multiple pages and were not empty.&lt;/p&gt;

&lt;p&gt;However, visual inspection was disappointing.&lt;/p&gt;

&lt;p&gt;The brochure still contained incorrect text wrapping, displaced headings and overlapping objects. The newsletter continued to show serious paragraph collisions and unreadable areas.&lt;/p&gt;

&lt;p&gt;I could not identify a meaningful visual improvement for these documents.&lt;/p&gt;

&lt;p&gt;There is an important qualification here: the Docker environment and original production environment did not initially have exactly the same font installations.&lt;/p&gt;

&lt;p&gt;This was therefore not a perfectly controlled renderer benchmark.&lt;/p&gt;

&lt;p&gt;It was sufficient, however, to establish that moving to LibreOffice 26.8 did not resolve the most significant visual defects in the documents I tested.&lt;/p&gt;

&lt;p&gt;A newer application version did not automatically mean a better conversion.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Testing another importer: Scribus
&lt;/h2&gt;

&lt;p&gt;I also investigated Scribus 1.6.1.&lt;/p&gt;

&lt;p&gt;Because this was a Linux server without a normal graphical desktop session, I used Xvfb to provide a virtual display for scripted processing.&lt;/p&gt;

&lt;p&gt;The conversion attempt used Scribus's Python interface to open a Publisher document and export a PDF.&lt;/p&gt;

&lt;p&gt;It did not work as expected.&lt;/p&gt;

&lt;p&gt;The importer reported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: Parsing failed!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workflow subsequently produced a PDF of approximately 1,244 bytes.&lt;/p&gt;

&lt;p&gt;On inspection, that file contained one essentially empty page rather than the expected two-page brochure.&lt;/p&gt;

&lt;p&gt;This was an important failed test.&lt;/p&gt;

&lt;p&gt;A generated PDF is not necessarily proof that a source document was successfully imported. Some workflows can reach an export stage even though the earlier import operation did not produce useful content.&lt;/p&gt;

&lt;p&gt;I treated the Scribus result as a failure rather than a successful conversion.&lt;/p&gt;

&lt;p&gt;It also highlighted why automated document-processing systems should validate more than process exit codes and output filenames.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Testing libmspub directly
&lt;/h2&gt;

&lt;p&gt;LibreOffice's Publisher import capability uses the open-source &lt;code&gt;libmspub&lt;/code&gt; library, so I wanted to see whether accessing the library's utilities directly could provide a useful alternative output.&lt;/p&gt;

&lt;p&gt;I launched a temporary Ubuntu 24.04 container and installed the available tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt-get update
apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; libmspub-tools
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The installed version was 0.1.4.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 1: PUB to SVG
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;pub2xhtml&lt;/code&gt; utility describes itself as a Publisher-to-SVG converter.&lt;/p&gt;

&lt;p&gt;I tested the brochure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pub2xhtml &lt;span class="se"&gt;\&lt;/span&gt;
  /work/SampleBrochure.pub &lt;span class="se"&gt;\&lt;/span&gt;
  /work/libmspub-output/SampleBrochure.svg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: SVG Generation failed!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I then attempted the simple sample:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pub2xhtml &lt;span class="se"&gt;\&lt;/span&gt;
  /work/Sample.pub &lt;span class="se"&gt;\&lt;/span&gt;
  /work/libmspub-output/Sample.svg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That returned a different error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: Unsupported file format!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither test produced a usable SVG document. The generated files were empty.&lt;/p&gt;

&lt;p&gt;These errors should not be conflated. The utility reported an unsupported format for one input and an SVG-generation failure for the other.&lt;/p&gt;

&lt;p&gt;I did not establish the precise internal cause of either failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 2: raw diagnostic output
&lt;/h3&gt;

&lt;p&gt;The package also provided &lt;code&gt;pub2raw&lt;/code&gt;, a utility intended for testing &lt;code&gt;libmspub&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;After checking its argument syntax, I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pub2raw /work/SampleBrochure.pub &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /work/libmspub-output/SampleBrochure.raw 2&amp;gt; /work/libmspub-output/pub2raw-error.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both generated files were zero bytes.&lt;/p&gt;

&lt;p&gt;There was no useful diagnostic content and no error text.&lt;/p&gt;

&lt;p&gt;I had not captured the utility's exit code, so I could not determine from this evidence whether parsing had completed successfully but produced no output, or failed silently.&lt;/p&gt;

&lt;p&gt;The direct library experiments did not provide a viable alternative conversion route.&lt;/p&gt;

&lt;p&gt;That was useful information, but not a reason to conclude that the library could never be improved or that its behaviour was identical for every Publisher file.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. A different outcome: converting Publisher to editable ODG
&lt;/h2&gt;

&lt;p&gt;At this point, I changed the question.&lt;/p&gt;

&lt;p&gt;Instead of asking only whether a Publisher document could be reproduced faithfully as a PDF, I wanted to know whether its underlying content could be recovered in a form that remained editable.&lt;/p&gt;

&lt;p&gt;I tested conversion to OpenDocument Drawing (&lt;code&gt;.odg&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Using the LibreOffice 26.8 Docker environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;docker&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--rm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"type=bind,source=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Get-Location&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.Path),target=/work"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nx"&gt;swiftvecto-pub-lo268&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nx"&gt;UserInstallation&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;file:///tmp/lo-pub-odg-profile&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--headless&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--convert-to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;odg:draw8&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--outdir&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;/work/odg-output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nx"&gt;/work/SampleBrochure.pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nx"&gt;/work/SampleNewsletter.pub&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The results were promising:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;convert /work/SampleBrochure.pub as a Draw document -&amp;gt; /work/odg-output/SampleBrochure.odg using filter : draw8
convert /work/SampleNewsletter.pub as a Draw document -&amp;gt; /work/odg-output/SampleNewsletter.odg using filter : draw8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generated files were:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Name                 Length
----                 ------
SampleBrochure.odg   102569
SampleNewsletter.odg 190794
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I opened the ODG documents in LibreOffice Draw to test whether they contained meaningful editable content.&lt;/p&gt;

&lt;p&gt;The important result was that individual text fields and objects in the brochure remained editable. They were not simply flattened into a single image of each page.&lt;/p&gt;

&lt;p&gt;Text could be changed within the converted document, and recovered page elements could be worked with independently.&lt;/p&gt;

&lt;p&gt;I also observed no noticeable difference in practical ODG editability between the LibreOffice 24.2 and 26.8 versions tested.&lt;/p&gt;

&lt;p&gt;That observation does not establish that their internal output structures were identical. It means I found no meaningful editability improvement attributable to the newer version.&lt;/p&gt;

&lt;p&gt;The overall result was still significant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Although the original layout could require correction, ODG offered a practical way to recover and continue editing the publication's contents.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For certain migration scenarios, that may be more valuable than a PDF that merely looks similar to the original.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;[Figure 3: Converted brochure opened in LibreOffice Draw, with an individual text object selected for editing.]&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  9. What about converting directly to DOCX?
&lt;/h2&gt;

&lt;p&gt;The next question was whether the Publisher documents could be converted into Microsoft Word's DOCX format.&lt;/p&gt;

&lt;p&gt;I attempted a direct export using the &lt;code&gt;Office Open XML Text&lt;/code&gt; filter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;docker&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--rm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"type=bind,source=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Get-Location&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.Path),target=/work"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nx"&gt;swiftvecto-pub-lo268&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nx"&gt;UserInstallation&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;file:///tmp/lo-pub-docx-profile&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--headless&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--convert-to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;docx:&lt;/span&gt;&lt;span class="s2"&gt;"Office Open XML Text"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--outdir&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;/work/docx-output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nx"&gt;/work/SampleBrochure.pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nx"&gt;/work/SampleNewsletter.pub&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LibreOffice recognised each input as a Draw document, but the export failed.&lt;/p&gt;

&lt;p&gt;The brochure produced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;convert /work/SampleBrochure.pub as a Draw document -&amp;gt; /work/docx-output/SampleBrochure.docx using filter : Office Open XML Text
Error: Please verify input parameters... (SfxBaseModel::impl_store &amp;lt;file:///work/docx-output/SampleBrochure.docx&amp;gt; failed: 0xc10(Error Area:Io Class:Write Code:16))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The newsletter produced the same class of error.&lt;/p&gt;

&lt;p&gt;This was not evidence that DOCX was an impossible destination under all circumstances.&lt;/p&gt;

&lt;p&gt;It showed that the direct Draw-to-DOCX export route I attempted was not supported successfully by that workflow.&lt;/p&gt;

&lt;p&gt;Publisher and Word also organise document content differently. Publisher's positioned page-layout objects do not necessarily map cleanly to a Word document's structure.&lt;/p&gt;

&lt;p&gt;A different approach might involve extracting text and images, reconstructing a DOCX document and accepting that some layout information would be lost.&lt;/p&gt;

&lt;p&gt;I did not implement that approach during this investigation.&lt;/p&gt;

&lt;p&gt;Rather than claim a working converter from a failed export, I left DOCX conversion for separate research.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Converting the resulting PDF into images
&lt;/h2&gt;

&lt;p&gt;One of the more straightforward experiments involved rendering converted Publisher pages as PNG images.&lt;/p&gt;

&lt;p&gt;The aim was not to improve layout fidelity. It was to determine whether a successfully generated PDF could be used as an intermediate format for producing individual page images.&lt;/p&gt;

&lt;p&gt;I used Poppler's &lt;code&gt;pdftoppm&lt;/code&gt; utility inside the test container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;docker&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--rm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--entrypoint&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;/bin/bash&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"type=bind,source=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Get-Location&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.Path),target=/work"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nx"&gt;swiftvecto-pub-lo268&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-c&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'pdftoppm -f 1 -l 2 -r 150 -png /work/output/SampleBrochure.pdf /work/image-output/brochure'&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command completed without reporting an error.&lt;/p&gt;

&lt;p&gt;The resulting files were:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;brochure-1.png     262755 bytes
brochure-2.png     706529 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both pages of the brochure had been rendered at 150 DPI.&lt;/p&gt;

&lt;p&gt;This established a useful two-stage pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Publisher (.pub)
       |
       v
Converted PDF
       |
       v
Poppler renderer
       |
       v
Individual PNG page images
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same concept was subsequently used in a web-based PUB-to-Images utility.&lt;/p&gt;

&lt;p&gt;However, it is important to understand what this pipeline does not achieve.&lt;/p&gt;

&lt;p&gt;If the intermediate PDF already contains overlapping paragraphs or incorrectly positioned images, the PNG output will reproduce those errors.&lt;/p&gt;

&lt;p&gt;Rasterisation preserves the appearance of the intermediate representation. It cannot reconstruct the layout of the original Publisher document.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Results across the conversion routes
&lt;/h2&gt;

&lt;p&gt;The following table summarises the tests rather than claiming general compatibility for every Publisher document.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;th&gt;Technical result&lt;/th&gt;
&lt;th&gt;Practical conclusion&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Simple PUB to PDF&lt;/td&gt;
&lt;td&gt;Successful&lt;/td&gt;
&lt;td&gt;Basic publications can convert acceptably&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complex PUB to PDF, LibreOffice 24.2&lt;/td&gt;
&lt;td&gt;Successful export, poor fidelity&lt;/td&gt;
&lt;td&gt;Generated PDF not necessarily suitable for final distribution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complex PUB to PDF, LibreOffice 26.8&lt;/td&gt;
&lt;td&gt;Successful export, poor fidelity&lt;/td&gt;
&lt;td&gt;Upgrade did not resolve observed layout defects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PUB to PDF through Scribus script&lt;/td&gt;
&lt;td&gt;Failed&lt;/td&gt;
&lt;td&gt;Import failure made the generated PDF unusable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PUB to SVG using &lt;code&gt;pub2xhtml&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Failed&lt;/td&gt;
&lt;td&gt;No usable SVG output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PUB to raw diagnostics using &lt;code&gt;pub2raw&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Empty output&lt;/td&gt;
&lt;td&gt;Inconclusive diagnostic result&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PUB to ODG&lt;/td&gt;
&lt;td&gt;Successful&lt;/td&gt;
&lt;td&gt;Recovered editable document content&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direct PUB to DOCX&lt;/td&gt;
&lt;td&gt;Failed&lt;/td&gt;
&lt;td&gt;Requested export filter did not produce usable DOCX&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Converted PDF to PNG&lt;/td&gt;
&lt;td&gt;Successful&lt;/td&gt;
&lt;td&gt;Page images generated, inheriting the PDF's appearance&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The most important distinction was not simply successful versus failed conversion.&lt;/p&gt;

&lt;p&gt;It was whether the output met the intended requirement.&lt;/p&gt;

&lt;p&gt;For viewing, I needed readable pages with acceptable layout preservation.&lt;/p&gt;

&lt;p&gt;For editing, I needed independently accessible text, images and other document elements.&lt;/p&gt;

&lt;p&gt;For archiving, I needed confidence that important content had not been lost or displaced.&lt;/p&gt;

&lt;p&gt;The same output format did not satisfy every requirement.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. What I would recommend for legacy Publisher documents
&lt;/h2&gt;

&lt;p&gt;Microsoft's official retirement guidance recommends preserving existing Publisher documents by converting them into other formats, including PDF. It also identifies Word and PowerPoint as alternatives for several common Publisher use cases.&lt;/p&gt;

&lt;p&gt;That is Microsoft's documented guidance.&lt;/p&gt;

&lt;p&gt;My testing addresses a narrower question: what can happen when alternative Linux-based tools are used to import existing PUB files?&lt;/p&gt;

&lt;p&gt;Based on these results, I would approach a Publisher migration according to the actual requirement.&lt;/p&gt;

&lt;p&gt;If Microsoft Publisher is still available through a functioning perpetual installation, exporting directly from the original application is a sensible option to evaluate first, particularly when visual fidelity matters. I did not benchmark direct Publisher exports in this investigation.&lt;/p&gt;

&lt;p&gt;For organisations working without Publisher, alternative conversion tools can provide access to the contents, but the output should be reviewed, especially for newsletters, brochures and other complex publications.&lt;/p&gt;

&lt;p&gt;If the objective is continued editing rather than static distribution, an editable drawing format such as ODG may be useful. The recovered content can still require layout adjustments, but at least the publication can remain workable.&lt;/p&gt;

&lt;p&gt;For image output, rendering an intermediate PDF is technically straightforward, provided the PDF is already acceptable.&lt;/p&gt;

&lt;p&gt;For DOCX, I would not promise reliable conversion on the basis of a supported filename or export filter alone. The actual document structure and selected conversion path matter.&lt;/p&gt;

&lt;p&gt;For large archives, I would also recommend retaining the original PUB files alongside any converted outputs.&lt;/p&gt;

&lt;p&gt;Conversion should not be treated as a reason to discard the only existing source document.&lt;/p&gt;

&lt;h2&gt;
  
  
  13. Limitations of this investigation
&lt;/h2&gt;

&lt;p&gt;The testing was deliberately practical rather than exhaustive.&lt;/p&gt;

&lt;p&gt;It used three named Publisher fixtures, not a representative sample of all Publisher versions or real-world publication designs.&lt;/p&gt;

&lt;p&gt;The LibreOffice comparison was not initially performed under identical font environments.&lt;/p&gt;

&lt;p&gt;I did not perform automated pixel-difference analysis or assign numerical fidelity scores.&lt;/p&gt;

&lt;p&gt;I also did not inspect the internal source code of &lt;code&gt;libmspub&lt;/code&gt; deeply enough to identify a specific parsing defect.&lt;/p&gt;

&lt;p&gt;The ODG editability assessment was based on opening and interacting with the converted documents, rather than a complete automated audit of every object.&lt;/p&gt;

&lt;p&gt;These limitations matter because they define what the results can and cannot establish.&lt;/p&gt;

&lt;p&gt;I can say that the tested complex Publisher files converted poorly to PDF under the tested LibreOffice versions.&lt;/p&gt;

&lt;p&gt;I cannot say that every Publisher document will convert poorly, or that no other conversion tool could produce a better result.&lt;/p&gt;

&lt;p&gt;I can say that the ODG files retained meaningful editability in my tests.&lt;/p&gt;

&lt;p&gt;I cannot say that ODG is a perfect replacement for Microsoft Publisher or that every publication feature survives intact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The investigation changed how I think about legacy document conversion.&lt;/p&gt;

&lt;p&gt;Initially, the question seemed straightforward: could I open a Publisher file on Linux and generate another format?&lt;/p&gt;

&lt;p&gt;The answer was often yes.&lt;/p&gt;

&lt;p&gt;But a successful export did not necessarily produce a document that was suitable for printing, sharing or further editing.&lt;/p&gt;

&lt;p&gt;The simple Publisher sample converted acceptably. The more complex brochure and newsletter exposed serious layout-fidelity problems that were not resolved simply by installing additional fonts or switching from LibreOffice 24.2 to 26.8.&lt;/p&gt;

&lt;p&gt;Scribus and direct &lt;code&gt;libmspub&lt;/code&gt; utilities did not produce a useful alternative through the approaches tested.&lt;/p&gt;

&lt;p&gt;Direct DOCX export failed.&lt;/p&gt;

&lt;p&gt;ODG, however, demonstrated that recovering editable content could still be worthwhile, even when the original appearance was not preserved perfectly.&lt;/p&gt;

&lt;p&gt;And PDF-to-PNG rendering showed how a successful intermediate conversion could support additional output formats, while also carrying forward any existing defects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For a legacy document migration, generating an output file is only the beginning of validation. The useful question is whether that output preserves what the organisation actually needs: appearance, information, editability, or some combination of the three.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That distinction matters more than whether a converter reports success.&lt;/p&gt;




&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://support.microsoft.com/en-us/publisher/microsoft-publisher-will-no-longer-be-supported-after-october-2026" rel="noopener noreferrer"&gt;Microsoft: Microsoft Publisher will no longer be supported after October 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.microsoft.com/en-gb/office/system-requirements/end-of-support-for-office-2021" rel="noopener noreferrer"&gt;Microsoft: End of support for Office 2021&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.libreoffice.org/" rel="noopener noreferrer"&gt;LibreOffice&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/LibreOffice/libmspub" rel="noopener noreferrer"&gt;The Document Foundation: libmspub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://poppler.freedesktop.org/" rel="noopener noreferrer"&gt;Poppler PDF rendering tools&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;The conversion commands and results documented above come from the practical tests performed during this investigation. Microsoft retirement information is based on Microsoft's published support documentation.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>libreoffice</category>
      <category>opensource</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>A Backup Isn't a Recovery Plan: Building a Docker-Based MySQL Restore Verifier</title>
      <dc:creator>Michael Oteng</dc:creator>
      <pubDate>Mon, 26 Oct 2026 22:02:54 +0000</pubDate>
      <link>https://dev.to/xetera1/a-backup-isnt-a-recovery-plan-building-a-docker-based-mysql-restore-verifier-2kic</link>
      <guid>https://dev.to/xetera1/a-backup-isnt-a-recovery-plan-building-a-docker-based-mysql-restore-verifier-2kic</guid>
      <description>&lt;p&gt;There is a significant difference between having a database backup and knowing that the database can be recovered.&lt;/p&gt;

&lt;p&gt;A backup command can finish successfully. An archive can exist in the expected directory. Its checksum can match after transferring it to another machine.&lt;/p&gt;

&lt;p&gt;Those are useful checks, but none of them proves that the database can actually be restored.&lt;/p&gt;

&lt;p&gt;I recently had to address this distinction while decommissioning a Laravel-based SaaS platform. The infrastructure was being retired, but I wanted to preserve the option of recovering the application and its data in the future.&lt;/p&gt;

&lt;p&gt;That meant treating the recovery process as something to test before deleting the original infrastructure, rather than something to investigate if the backups were ever needed.&lt;/p&gt;

&lt;p&gt;The experience eventually led me to build and publish a small open-source project: &lt;a href="https://github.com/xetera1-star/recoverable-saas-decommission" rel="noopener noreferrer"&gt;Recoverable SaaS Decommission&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with backup verification
&lt;/h2&gt;

&lt;p&gt;Database backups are often considered successful once the backup file has been created.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mysqldump ... &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; database.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or, with compression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mysqldump ... | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; database.sql.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resulting file may be present, nonempty and correctly transferred to another machine.&lt;/p&gt;

&lt;p&gt;But several questions remain unanswered.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Will the SQL import into a compatible database engine?&lt;/li&gt;
&lt;li&gt;Does the restored database contain the expected tables?&lt;/li&gt;
&lt;li&gt;Are important records accessible?&lt;/li&gt;
&lt;li&gt;Can relationships between records be verified?&lt;/li&gt;
&lt;li&gt;Is the restore procedure independent of the original hosting environment?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;File integrity and database recoverability are related, but they are not the same thing.&lt;/p&gt;

&lt;p&gt;A SHA-256 comparison can establish that two files contain identical bytes. It cannot establish that those bytes represent a complete or usable database backup.&lt;/p&gt;

&lt;p&gt;That distinction becomes particularly important during infrastructure decommissioning.&lt;/p&gt;

&lt;p&gt;Once the original database server has been deleted, discovering that the backup is unusable becomes a much more serious problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The original decommissioning exercise
&lt;/h2&gt;

&lt;p&gt;The application involved Laravel, PHP, Nginx, Ubuntu and a managed MySQL database hosted on DigitalOcean.&lt;/p&gt;

&lt;p&gt;It also used a multi-tenant architecture.&lt;/p&gt;

&lt;p&gt;The decommissioning process involved preserving several categories of assets:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A database export.&lt;/li&gt;
&lt;li&gt;Application files and configuration.&lt;/li&gt;
&lt;li&gt;Persistent application storage.&lt;/li&gt;
&lt;li&gt;Server configuration needed to understand the original deployment.&lt;/li&gt;
&lt;li&gt;Recovery credentials and encryption material, stored separately from the public project.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The archives were encrypted, transferred away from the original server and checked using SHA-256 digests.&lt;/p&gt;

&lt;p&gt;The database backup then underwent an independent restore test using Docker on a Windows machine with Ubuntu WSL.&lt;/p&gt;

&lt;p&gt;That exercise involved importing the database into a separate MySQL environment, inspecting the restored structure and querying representative records.&lt;/p&gt;

&lt;p&gt;The database restoration succeeded.&lt;/p&gt;

&lt;p&gt;Importantly, this was a database recovery test, not a full clean-room reconstruction of the SaaS application. I did not use that result to claim that every component of the original application stack had been independently recovered.&lt;/p&gt;

&lt;p&gt;With the necessary recovery evidence and archives in place, the original infrastructure could then be retired.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning the process into a reusable tool
&lt;/h2&gt;

&lt;p&gt;After completing the decommissioning, I wanted a reusable way to verify future database backups without manually repeating the Docker setup, import and cleanup procedure.&lt;/p&gt;

&lt;p&gt;I deliberately kept the first implementation small.&lt;/p&gt;

&lt;p&gt;Rather than building a complete disaster-recovery framework, I focused on one specific question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can this MySQL backup be imported into a disposable database, and do the supplied verification checks pass?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The resulting Bash verifier is available in the repository:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xetera1-star/recoverable-saas-decommission/blob/main/mysql/verify-restore.sh" rel="noopener noreferrer"&gt;mysql/verify-restore.sh&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It uses Docker to create a temporary MySQL environment, restore the supplied SQL backup, perform verification and remove the container afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the verifier works
&lt;/h2&gt;

&lt;p&gt;The workflow is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SQL backup
    |
    v
Validate input
    |
    v
Start isolated MySQL container
    |
    v
Wait for authenticated readiness
    |
    v
Import SQL backup
    |
    v
Check restored table count
    |
    v
Run optional SQL assertions
    |
    v
Report PASS / FAIL
    |
    v
Remove temporary container
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tool supports ordinary &lt;code&gt;.sql&lt;/code&gt; backups and gzip-compressed &lt;code&gt;.sql.gz&lt;/code&gt; files.&lt;/p&gt;

&lt;p&gt;For isolation, the container is started without published ports and with Docker networking disabled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--network&lt;/span&gt; none &lt;span class="se"&gt;\&lt;/span&gt;
    ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The temporary MySQL password is generated using OpenSSL and supplied through a restricted file mounted into the container.&lt;/p&gt;

&lt;p&gt;The verifier also uses an exit trap to remove the container and its associated temporary resources when execution ends.&lt;/p&gt;

&lt;p&gt;A key design decision was not to connect to the production database at all.&lt;/p&gt;

&lt;p&gt;The verifier works entirely with a supplied backup file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running a restore test
&lt;/h2&gt;

&lt;p&gt;The repository contains a synthetic SQL fixture and a corresponding set of verification queries.&lt;/p&gt;

&lt;p&gt;This means the example can be tested without production data.&lt;/p&gt;

&lt;p&gt;On Linux, with Bash and Docker available:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash mysql/verify-restore.sh &lt;span class="se"&gt;\&lt;/span&gt;
  fixtures/sample-schema.sql &lt;span class="se"&gt;\&lt;/span&gt;
  mysql/verification-queries.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Windows, I tested the Bash verifier using Ubuntu WSL with Docker Desktop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;bash&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;mysql/verify-restore.sh&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;fixtures/sample-schema.sql&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;mysql/verification-queries.sql&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fixture contains fictional organisations, users and activity records.&lt;/p&gt;

&lt;p&gt;The verifier imports the database and executes the provided assertions.&lt;/p&gt;

&lt;p&gt;A successful run produces output similar to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[PASS] MySQL authenticated and ready.
[PASS] SQL import completed.
[PASS] Restored base tables: 3
[PASS] Custom verification assertions passed.

RESTORE VERIFICATION: PASS
MySQL image: mysql:8.0
Restored base tables: 3
Custom assertions: PASS

[INFO] Removed disposable MySQL container.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same process can be used with gzip-compressed SQL backups.&lt;/p&gt;

&lt;p&gt;When adapting this to another application, the important part is supplying verification queries that reflect that application's actual schema and recovery requirements.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an import alone is insufficient
&lt;/h2&gt;

&lt;p&gt;An SQL import can finish without an error while still leaving important questions unanswered.&lt;/p&gt;

&lt;p&gt;For example, a database may contain the expected tables but lack records that should have been included in the backup.&lt;/p&gt;

&lt;p&gt;Or a backup may be internally consistent but represent an older recovery point than intended.&lt;/p&gt;

&lt;p&gt;For that reason, the verifier supports application-specific SQL assertions.&lt;/p&gt;

&lt;p&gt;These queries can check expected tables, record counts, relationships or other recovery invariants.&lt;/p&gt;

&lt;p&gt;The current verifier expects the assertion file to produce exactly one scalar value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A result of &lt;code&gt;1&lt;/code&gt; indicates that the supplied assertions passed.&lt;/p&gt;

&lt;p&gt;Any other result is treated as a failure.&lt;/p&gt;

&lt;p&gt;Of course, &lt;code&gt;SELECT 1&lt;/code&gt; alone proves nothing meaningful about the restored data. It is simply the interface contract. Real verification queries should encode useful expectations.&lt;/p&gt;

&lt;p&gt;The repository includes an example assertion file using synthetic data.&lt;/p&gt;

&lt;p&gt;There is also an important limitation: an assertion is only as strong as the evidence behind it.&lt;/p&gt;

&lt;p&gt;Without a trusted source baseline, the tool cannot prove that every record from the original database has been recovered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing failure conditions
&lt;/h2&gt;

&lt;p&gt;I didn't want a tool that only demonstrated a successful restoration.&lt;/p&gt;

&lt;p&gt;The failure paths mattered just as much.&lt;/p&gt;

&lt;p&gt;For the initial release, I tested five scenarios:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Observed result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Valid &lt;code&gt;.sql&lt;/code&gt; backup&lt;/td&gt;
&lt;td&gt;Restoration and assertions passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Valid &lt;code&gt;.sql.gz&lt;/code&gt; backup&lt;/td&gt;
&lt;td&gt;Restoration and assertions passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing backup file&lt;/td&gt;
&lt;td&gt;Input rejected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deliberately failing SQL assertion&lt;/td&gt;
&lt;td&gt;Verification rejected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Malformed SQL backup&lt;/td&gt;
&lt;td&gt;MySQL import failed and the verifier reported failure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The failed-import and failed-assertion scenarios also demonstrated that the disposable container was removed afterwards.&lt;/p&gt;

&lt;p&gt;These tests are useful evidence for the core workflow, but they are not a comprehensive security audit or a claim of compatibility with every MySQL dump format.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security considerations
&lt;/h2&gt;

&lt;p&gt;A disposable database is not an excuse to ignore security.&lt;/p&gt;

&lt;p&gt;The current implementation follows several basic principles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It does not require access to a production database.&lt;/li&gt;
&lt;li&gt;MySQL is not exposed through published network ports.&lt;/li&gt;
&lt;li&gt;Docker networking is disabled for the restore container.&lt;/li&gt;
&lt;li&gt;Temporary credentials are generated rather than hardcoded.&lt;/li&gt;
&lt;li&gt;The script attempts to remove only the container it created.&lt;/li&gt;
&lt;li&gt;SQL errors and failed assertions cause verification to fail.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;However, SQL backups and assertion files should still be treated as trusted inputs.&lt;/p&gt;

&lt;p&gt;The verifier executes SQL with administrative privileges inside the temporary database. It is not intended to safely process arbitrary malicious SQL or MySQL client commands.&lt;/p&gt;

&lt;p&gt;There are also operational limitations. Database imports and Docker image downloads are not individually time-limited, and the initial release uses the &lt;code&gt;mysql:8.0&lt;/code&gt; image tag rather than an immutable digest.&lt;/p&gt;

&lt;p&gt;These are considerations to understand before using the tool in a more demanding operational environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the tool does not verify
&lt;/h2&gt;

&lt;p&gt;It is important not to overstate what a database restore test proves.&lt;/p&gt;

&lt;p&gt;For a Laravel SaaS application, complete recovery may also require:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Application source code and Composer dependencies.&lt;/li&gt;
&lt;li&gt;Environment variables and encryption keys.&lt;/li&gt;
&lt;li&gt;Uploaded files and persistent storage.&lt;/li&gt;
&lt;li&gt;Web server and PHP configuration.&lt;/li&gt;
&lt;li&gt;Queue workers and scheduled tasks.&lt;/li&gt;
&lt;li&gt;DNS, TLS certificates and external services.&lt;/li&gt;
&lt;li&gt;Tenant-specific configuration and application-level testing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A successful database import cannot establish that all of those components are recoverable.&lt;/p&gt;

&lt;p&gt;The verifier answers a narrower question: whether the supplied MySQL backup can be restored and whether the selected checks pass.&lt;/p&gt;

&lt;p&gt;That is still a useful piece of recovery evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I published the project
&lt;/h2&gt;

&lt;p&gt;The work started as an operational requirement: retire an application without losing the ability to recover its preserved data.&lt;/p&gt;

&lt;p&gt;The resulting verification procedure was useful enough to make reusable.&lt;/p&gt;

&lt;p&gt;I have published the first release, &lt;code&gt;v0.1.0&lt;/code&gt;, under the MIT License, with synthetic fixtures and documentation so others can examine and adapt the approach without needing access to the original system.&lt;/p&gt;

&lt;p&gt;The repository is available here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/xetera1-star/recoverable-saas-decommission" rel="noopener noreferrer"&gt;Recoverable SaaS Decommission on GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The initial version is intentionally limited to MySQL restore verification. It is not a finished, general-purpose disaster-recovery platform.&lt;/p&gt;

&lt;p&gt;For me, the main lesson from the exercise is that creating a backup and demonstrating recovery should be treated as separate operational activities.&lt;/p&gt;

&lt;p&gt;Both matter, but only one involves actually attempting the restore.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't test whether you can create a backup. Test whether you can restore from it.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>mysql</category>
      <category>docker</category>
      <category>backup</category>
    </item>
    <item>
      <title>Decommissioning a Laravel SaaS Without Losing the Ability to Restore It</title>
      <dc:creator>Michael Oteng</dc:creator>
      <pubDate>Mon, 26 Oct 2026 07:19:09 +0000</pubDate>
      <link>https://dev.to/xetera1/decommissioning-a-laravel-saas-without-losing-the-ability-to-restore-it-42hb</link>
      <guid>https://dev.to/xetera1/decommissioning-a-laravel-saas-without-losing-the-ability-to-restore-it-42hb</guid>
      <description>&lt;p&gt;Recently, I decommissioned the infrastructure behind &lt;strong&gt;Scholent&lt;/strong&gt;, a multi-tenant Laravel SaaS application I built.&lt;/p&gt;

&lt;p&gt;Deleting the infrastructure itself was easy.&lt;/p&gt;

&lt;p&gt;The more important question was:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If I needed Scholent again six months or two years from now, could I actually recover it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I didn't want the answer to be:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I have a backup somewhere.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I wanted evidence that the critical recovery material was intact and, particularly for the database, actually restorable.&lt;/p&gt;

&lt;p&gt;So I treated this less as a server deletion exercise and more as a small disaster-recovery exercise.&lt;/p&gt;

&lt;p&gt;The process eventually looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Inventory
   ↓
Archive
   ↓
Encrypt
   ↓
Transfer off-server
   ↓
Verify integrity
   ↓
Restore independently
   ↓
Inspect recovered data
   ↓
Document reconstruction
   ↓
Decommission infrastructure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The distinction between &lt;strong&gt;having a backup&lt;/strong&gt; and &lt;strong&gt;having tested recovery material&lt;/strong&gt; became the most important part of the exercise.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Commands in this article have been sanitised where necessary. Credentials, encryption secrets, application keys and other sensitive infrastructure information are deliberately excluded or represented using placeholders.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What was being decommissioned?
&lt;/h2&gt;

&lt;p&gt;Scholent wasn't simply a Laravel directory sitting on a VPS.&lt;/p&gt;

&lt;p&gt;The production environment included:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ubuntu 22.04&lt;/li&gt;
&lt;li&gt;Laravel 12&lt;/li&gt;
&lt;li&gt;PHP 8.3&lt;/li&gt;
&lt;li&gt;MySQL 8&lt;/li&gt;
&lt;li&gt;Nginx&lt;/li&gt;
&lt;li&gt;Redis&lt;/li&gt;
&lt;li&gt;Supervisor&lt;/li&gt;
&lt;li&gt;Let's Encrypt&lt;/li&gt;
&lt;li&gt;Stancl-based multi-tenancy&lt;/li&gt;
&lt;li&gt;DigitalOcean compute&lt;/li&gt;
&lt;li&gt;DigitalOcean Managed MySQL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The deployed Laravel application lived under:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/var/www/school-platform
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There were also existing infrastructure snapshots.&lt;/p&gt;

&lt;p&gt;This matters because preserving a Git repository alone would not necessarily preserve enough information to reconstruct the application.&lt;/p&gt;

&lt;p&gt;A working Laravel SaaS can depend on considerably more:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application source
        +
Composer dependencies
        +
Database
        +
Environment configuration
        +
APP_KEY / encryption material
        +
Web server configuration
        +
Queue workers
        +
Redis
        +
Scheduled jobs
        +
DNS
        +
TLS
        +
Tenant configuration
        +
External services
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the first job was not creating a &lt;code&gt;.zip&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;It was determining &lt;strong&gt;what constituted the system&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Inventory the environment before touching anything
&lt;/h2&gt;

&lt;p&gt;Before removing infrastructure, I documented the environment and the components required to reconstruct it.&lt;/p&gt;

&lt;p&gt;For a PHP application, even something as basic as recording versions matters.&lt;/p&gt;

&lt;p&gt;Typical checks include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mysql &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and for Laravel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Along with the obvious questions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Which PHP version?
Which MySQL version?
Which Laravel version?
Where is Redis?
How are queues processed?
How is Nginx configured?
How is TLS handled?
How does tenancy work?
Which scheduled jobs exist?
Which external services does the application depend on?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This information is easy to obtain while the server exists.&lt;/p&gt;

&lt;p&gt;It becomes considerably less convenient after the server has been deleted.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Create independent recovery artefacts
&lt;/h2&gt;

&lt;p&gt;I separated the recovery material into broad categories rather than treating the existing server as the archive.&lt;/p&gt;

&lt;p&gt;That included:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application
Database
Server configuration
Recovery documentation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application and relevant configuration were archived independently.&lt;/p&gt;

&lt;p&gt;A simplified example of the approach is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-czf&lt;/span&gt; application.tar.gz /var/www/school-platform
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with relevant server configuration archived separately.&lt;/p&gt;

&lt;p&gt;The actual application archive was approximately &lt;strong&gt;16 MB&lt;/strong&gt;, while the server-configuration archive was approximately &lt;strong&gt;121 KB&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Those numbers aren't particularly important by themselves.&lt;/p&gt;

&lt;p&gt;What mattered was that the recovery set contained the material I expected it to contain.&lt;/p&gt;

&lt;p&gt;The database was handled separately because I wanted to test it independently later.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Encrypt sensitive recovery material
&lt;/h2&gt;

&lt;p&gt;Application archives and database exports can contain information that should not simply be copied into general storage unprotected.&lt;/p&gt;

&lt;p&gt;Depending on the application, this may include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;environment configuration&lt;/li&gt;
&lt;li&gt;database connection information&lt;/li&gt;
&lt;li&gt;API credentials&lt;/li&gt;
&lt;li&gt;application encryption material&lt;/li&gt;
&lt;li&gt;user data&lt;/li&gt;
&lt;li&gt;internal infrastructure information&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I encrypted the sensitive archives using AES-256-CBC with PBKDF2-based key derivation and a deliberately high iteration count.&lt;/p&gt;

&lt;p&gt;The general form was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl enc &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-aes-256-cbc&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-salt&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-pbkdf2&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-iter&lt;/span&gt; &amp;lt;iteration-count&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-in&lt;/span&gt; &amp;lt;archive&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-out&lt;/span&gt; &amp;lt;archive&amp;gt;.enc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real password and sensitive parameters obviously do not belong in a public article.&lt;/p&gt;

&lt;p&gt;The same applies to Laravel's &lt;code&gt;APP_KEY&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For some Laravel applications, losing the original &lt;code&gt;APP_KEY&lt;/code&gt; can mean losing the ability to decrypt data that was encrypted using it.&lt;/p&gt;

&lt;p&gt;So preserving recovery secrets matters.&lt;/p&gt;

&lt;p&gt;But preserving them &lt;strong&gt;inside an unprotected archive&lt;/strong&gt; rather defeats the point.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Get the recovery material off the infrastructure
&lt;/h2&gt;

&lt;p&gt;This is an obvious rule, but an important one:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A backup that exists only on the infrastructure you are about to destroy is not an acceptable long-term backup.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Once the archives were prepared, I copied them away from the DigitalOcean infrastructure.&lt;/p&gt;

&lt;p&gt;I retained independent copies outside the server environment, including local storage and Google Drive.&lt;/p&gt;

&lt;p&gt;At this point, however, I still only knew that I had files.&lt;/p&gt;

&lt;p&gt;I didn't yet know that those files would recover anything.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Verify the transferred files
&lt;/h2&gt;

&lt;p&gt;After transferring the archives, I calculated SHA-256 hashes.&lt;/p&gt;

&lt;p&gt;On Linux, that can be done with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sha256sum&lt;/span&gt; &amp;lt;archive&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same archive can then be hashed again after transfer.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Server archive SHA-256
        ↓
        == ?
        ↑
Downloaded archive SHA-256
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hashes matched.&lt;/p&gt;

&lt;p&gt;That established that the transferred files were byte-for-byte identical to the files produced on the server.&lt;/p&gt;

&lt;p&gt;Useful?&lt;/p&gt;

&lt;p&gt;Absolutely.&lt;/p&gt;

&lt;p&gt;Proof that the application could be recovered?&lt;/p&gt;

&lt;p&gt;No.&lt;/p&gt;

&lt;p&gt;A checksum answers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Has this file changed?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It doesn't answer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is this file actually useful?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That required a restore test.&lt;/p&gt;




&lt;h1&gt;
  
  
  6. Test the database backup independently
&lt;/h1&gt;

&lt;p&gt;This was the most important technical test in the process.&lt;/p&gt;

&lt;p&gt;I didn't want to verify the database backup by importing it into the original production database.&lt;/p&gt;

&lt;p&gt;That would still leave too much dependency on the existing environment.&lt;/p&gt;

&lt;p&gt;Instead, I created a &lt;strong&gt;disposable MySQL environment using Docker&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The idea was straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Production database
        │
        │ export
        ▼
Encrypted database archive
        │
        │ copy elsewhere
        ▼
Independent machine
        │
        │ decrypt / decompress
        ▼
Disposable MySQL container
        │
        │ import
        ▼
Inspect recovered database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the archive failed here, I still had the live infrastructure available to investigate and create another backup.&lt;/p&gt;

&lt;p&gt;That's exactly when I wanted failure to occur.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before deletion.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Start a clean MySQL container
&lt;/h2&gt;

&lt;p&gt;I used Docker to provide an isolated MySQL 8 environment.&lt;/p&gt;

&lt;p&gt;A simplified version of the command is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; scholent-restore-test &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;MYSQL_ROOT_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;temporary-test-password&amp;gt;'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;MYSQL_DATABASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;scholent_restore &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  mysql:8.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The restore environment was running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MySQL 8.0.46
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using a disposable container had several advantages.&lt;/p&gt;

&lt;p&gt;It didn't require installing another permanent MySQL instance.&lt;/p&gt;

&lt;p&gt;It kept the restore isolated from anything important.&lt;/p&gt;

&lt;p&gt;And when the verification was finished, the entire test environment could simply be removed.&lt;/p&gt;

&lt;p&gt;Before restoring anything, the container could be checked with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the MySQL instance interrogated independently.&lt;/p&gt;

&lt;p&gt;The important point was that this database server had &lt;strong&gt;no dependency on the original Scholent database infrastructure&lt;/strong&gt;.&lt;/p&gt;




&lt;h1&gt;
  
  
  8. Restore the backup, not the production database
&lt;/h1&gt;

&lt;p&gt;The encrypted database archive then had to survive the reverse process.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Encrypted archive
      ↓
Decrypt
      ↓
Decompress
      ↓
MySQL import
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact recovery pipeline depends on how the original database archive was packaged.&lt;/p&gt;

&lt;p&gt;The important thing was that I was feeding the archived database into the disposable MySQL environment rather than querying the original production database.&lt;/p&gt;

&lt;p&gt;A conventional import into the container would look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; scholent-restore-test &lt;span class="se"&gt;\&lt;/span&gt;
  mysql &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-uroot&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;temporary-test-password&amp;gt;'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  scholent_restore &amp;lt; scholent.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my case, the archived database was decrypted and decompressed as part of the recovery process before being imported.&lt;/p&gt;

&lt;p&gt;That exercised several parts of the recovery chain at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Can I access the encrypted archive?
            ↓
Can it be decrypted?
            ↓
Can the database payload be recovered?
            ↓
Will MySQL accept it?
            ↓
Does the restored database contain what I expect?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The import completed successfully.&lt;/p&gt;

&lt;p&gt;But I deliberately didn't stop there.&lt;/p&gt;




&lt;h1&gt;
  
  
  9. "Import succeeded" isn't enough either
&lt;/h1&gt;

&lt;p&gt;A database import returning without an obvious error is encouraging.&lt;/p&gt;

&lt;p&gt;It isn't the same as checking the restored database.&lt;/p&gt;

&lt;p&gt;So I queried the restored environment.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; scholent-restore-test &lt;span class="se"&gt;\&lt;/span&gt;
  mysql &lt;span class="nt"&gt;-uroot&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;USE&lt;/span&gt; &lt;span class="n"&gt;scholent_restore&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;TABLES&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The restored Scholent database contained:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;82 tables
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was the first useful structural verification.&lt;/p&gt;

&lt;p&gt;The schema had actually been reconstructed inside the independent MySQL environment.&lt;/p&gt;

&lt;p&gt;But I wanted to go one level further.&lt;/p&gt;




&lt;h1&gt;
  
  
  10. Check representative application data
&lt;/h1&gt;

&lt;p&gt;Rather than attempting to manually inspect all 82 tables, I selected representative areas of the application.&lt;/p&gt;

&lt;p&gt;Queries equivalent to the following were used:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;tenants&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;students&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;assessments&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;behaviour_records&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The restored database returned representative counts including:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tenants               4
students              69
users                 29
assessments           11
behaviour_records      4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was much more useful than simply knowing that &lt;code&gt;mysqldump&lt;/code&gt; had produced a file.&lt;/p&gt;

&lt;p&gt;At this point I knew that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ The archive existed
✓ The transferred archive matched the original
✓ The protected database material could be recovered
✓ MySQL could import it independently
✓ The database schema had been reconstructed
✓ 82 tables were present
✓ Representative application data was present
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a substantially stronger recovery position.&lt;/p&gt;




&lt;h1&gt;
  
  
  11. What exactly had I proved?
&lt;/h1&gt;

&lt;p&gt;It's important not to overstate the result.&lt;/p&gt;

&lt;p&gt;I had &lt;strong&gt;not&lt;/strong&gt; rebuilt the complete Scholent production environment from zero and run a full end-to-end application acceptance test.&lt;/p&gt;

&lt;p&gt;That would have been a different exercise.&lt;/p&gt;

&lt;p&gt;What I had independently tested was the critical database recovery path.&lt;/p&gt;

&lt;p&gt;The distinction matters.&lt;/p&gt;

&lt;p&gt;I had evidence for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Archive
   ↓
Transfer
   ↓
Integrity verification
   ↓
Decryption
   ↓
Database recovery
   ↓
Independent MySQL import
   ↓
Schema verification
   ↓
Representative data verification
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reconstruction of the broader Laravel stack was documented in the recovery runbook rather than fully executed during this decommissioning exercise.&lt;/p&gt;

&lt;p&gt;I think that distinction is important whenever we talk about disaster recovery.&lt;/p&gt;

&lt;p&gt;"Backup tested" can mean many different things.&lt;/p&gt;

&lt;p&gt;It is better to state precisely &lt;strong&gt;what was actually tested&lt;/strong&gt;.&lt;/p&gt;




&lt;h1&gt;
  
  
  12. Create the recovery runbook
&lt;/h1&gt;

&lt;p&gt;Once I knew the critical data could be recovered, I documented how the wider system would be reconstructed.&lt;/p&gt;

&lt;p&gt;The runbook covered the major dependencies around:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ubuntu
PHP
Laravel
MySQL
Nginx
Redis
Supervisor
SSL
DNS
Tenant routing
Application configuration
Encryption material
Post-restore verification
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The purpose wasn't to create documentation that could rebuild everything automatically.&lt;/p&gt;

&lt;p&gt;It was to reduce ambiguity.&lt;/p&gt;

&lt;p&gt;Future me shouldn't have to ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How did I configure the workers again?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;or:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Was Redis actually required?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;or:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How were tenant domains routed?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;or, considerably worse:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Where is the encryption key for this data?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A good recovery archive should contain both the &lt;strong&gt;material&lt;/strong&gt; and enough &lt;strong&gt;knowledge&lt;/strong&gt; to make sense of that material.&lt;/p&gt;




&lt;h1&gt;
  
  
  13. Snapshots are useful, but I didn't want them to be the archive
&lt;/h1&gt;

&lt;p&gt;The DigitalOcean environment also had existing snapshots.&lt;/p&gt;

&lt;p&gt;It would have been easy to leave them there and consider the system archived.&lt;/p&gt;

&lt;p&gt;I chose not to make that the long-term recovery strategy.&lt;/p&gt;

&lt;p&gt;Snapshots are useful operational tools, but for long-term archival I preferred independent recovery artefacts.&lt;/p&gt;

&lt;p&gt;That gave me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application archive
Database archive
Configuration archive
Recovery documentation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;rather than:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Hopefully that old provider snapshot still gives me everything."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The independent approach is also more portable.&lt;/p&gt;

&lt;p&gt;If I eventually rebuild Scholent somewhere else, the recovery strategy isn't inherently tied to the original compute instance.&lt;/p&gt;




&lt;h1&gt;
  
  
  14. Only then did I destroy the infrastructure
&lt;/h1&gt;

&lt;p&gt;At this point, I had moved through several different verification stages.&lt;/p&gt;

&lt;h3&gt;
  
  
  Application and configuration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ Archived
✓ Encrypted where required
✓ Copied off-server
✓ Integrity checked
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Database
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ Exported
✓ Protected
✓ Copied independently
✓ Decrypted successfully
✓ Restored into disposable MySQL
✓ 82 tables recovered
✓ Representative records verified
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Recovery knowledge
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ Infrastructure documented
✓ Reconstruction procedure documented
✓ Critical configuration requirements identified
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only after those checks did I proceed with removing the paid DigitalOcean infrastructure.&lt;/p&gt;

&lt;p&gt;That included the relevant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Droplet
Managed MySQL instance
Infrastructure snapshots
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Obsolete DNS records were also removed.&lt;/p&gt;

&lt;p&gt;The domain and email did not need to disappear simply because the compute infrastructure was being retired, so they were retained.&lt;/p&gt;

&lt;p&gt;That's another distinction I think is useful:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decommissioning infrastructure doesn't necessarily mean destroying the application identity or intellectual property associated with it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My objective was to stop paying for infrastructure I wasn't currently using without turning that cost-saving decision into irreversible data loss.&lt;/p&gt;




&lt;h1&gt;
  
  
  15. Clean up the disposable recovery environment too
&lt;/h1&gt;

&lt;p&gt;One advantage of using Docker for this kind of verification is that the recovery environment itself doesn't need to become another permanent piece of infrastructure.&lt;/p&gt;

&lt;p&gt;Once verification was complete, the test container could be removed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker stop scholent-restore-test
docker &lt;span class="nb"&gt;rm &lt;/span&gt;scholent-restore-test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a dedicated Docker volume had been created for the exercise, that could also be removed once it was no longer required.&lt;/p&gt;

&lt;p&gt;The restore environment had served its purpose:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;prove that the archived database could reconstruct usable data independently of production.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Then it could disappear too.&lt;/p&gt;




&lt;h1&gt;
  
  
  The different levels of backup confidence
&lt;/h1&gt;

&lt;p&gt;This exercise made me think about backup verification as a progression.&lt;/p&gt;

&lt;h3&gt;
  
  
  Level 1: The backup command completed
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I probably have a backup.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Level 2: The file exists
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I definitely have a file.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Level 3: The checksum matches after transfer
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I know the file I downloaded is the same file I created.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Level 4: The encrypted archive can be recovered
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I can still access the protected contents.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Level 5: The database imports successfully elsewhere
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The backup can reconstruct a database independently.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Level 6: Schema and representative data are verified
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The reconstructed database resembles the application data I intended to preserve.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Level 7: Full application recovery is tested
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The complete service can be reconstructed and validated.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For this decommissioning exercise, I independently tested through the database-recovery and data-verification stages.&lt;/p&gt;

&lt;p&gt;I documented the remaining application reconstruction requirements rather than claiming a complete end-to-end disaster-recovery test that I hadn't performed.&lt;/p&gt;




&lt;h1&gt;
  
  
  A practical Laravel SaaS decommissioning checklist
&lt;/h1&gt;

&lt;p&gt;If I were retiring another Laravel application tomorrow, my high-level checklist would look something like this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Understand what exists
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ ] Application/version inventory
[ ] PHP version
[ ] Database/version
[ ] Redis/cache dependencies
[ ] Queue configuration
[ ] Scheduled jobs
[ ] Web server configuration
[ ] SSL configuration
[ ] DNS requirements
[ ] External services
[ ] Tenant-specific architecture
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Preserve it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ ] Application/source
[ ] Database export
[ ] Relevant environment configuration
[ ] APP_KEY and other required secrets
[ ] Nginx/server configuration
[ ] Queue/worker configuration
[ ] Relevant storage
[ ] Recovery documentation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Protect it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ ] Encrypt sensitive recovery material
[ ] Store recovery credentials separately
[ ] Copy archives away from production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Verify it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ ] Generate checksums
[ ] Verify transferred copies
[ ] Decrypt the archive
[ ] Restore the database independently
[ ] Inspect schema
[ ] Query representative records
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Document it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ ] Write recovery sequence
[ ] Record software versions
[ ] Record infrastructure dependencies
[ ] Document DNS/TLS requirements
[ ] Document tenancy
[ ] Record post-restoration verification
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Only then
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ ] Remove compute
[ ] Remove managed databases
[ ] Remove unnecessary snapshots
[ ] Remove obsolete DNS
[ ] Verify billing/resources
[ ] Retain domains/services that still have a purpose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  What I'd change next time
&lt;/h1&gt;

&lt;p&gt;The biggest improvement I'd make is to treat the recovery runbook as a living part of the application rather than something created primarily when the application is being retired.&lt;/p&gt;

&lt;p&gt;The same applies to restore testing.&lt;/p&gt;

&lt;p&gt;Backups are easy to automate.&lt;/p&gt;

&lt;p&gt;Restore tests are easier to postpone.&lt;/p&gt;

&lt;p&gt;But the restore is the part that actually tells you whether the backup strategy works.&lt;/p&gt;

&lt;p&gt;I'd also consider automating more of the verification process for a system that remained operational long-term.&lt;/p&gt;

&lt;p&gt;For example, a controlled process could periodically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Create backup
      ↓
Verify checksum
      ↓
Create disposable database
      ↓
Restore
      ↓
Run verification queries
      ↓
Report result
      ↓
Destroy test environment
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docker makes that kind of disposable recovery testing considerably easier.&lt;/p&gt;




&lt;h1&gt;
  
  
  Final thought
&lt;/h1&gt;

&lt;p&gt;Deleting the Scholent infrastructure took very little time.&lt;/p&gt;

&lt;p&gt;Getting to the point where I was comfortable deleting it took considerably longer.&lt;/p&gt;

&lt;p&gt;And that's how I think it should be.&lt;/p&gt;

&lt;p&gt;The process ultimately became:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Inventory
   ↓
Archive
   ↓
Encrypt
   ↓
Transfer
   ↓
Hash
   ↓
Restore
   ↓
Inspect
   ↓
Document
   ↓
Decommission
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The most important lesson wasn't really about DigitalOcean, Docker, MySQL or Laravel.&lt;/p&gt;

&lt;p&gt;It was this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Don't test whether you can create a backup. Test whether you can restore from it.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A backup file tells me that I saved something.&lt;/p&gt;

&lt;p&gt;Seeing &lt;strong&gt;82 tables and the expected application data inside a clean MySQL container&lt;/strong&gt; tells me considerably more.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>devops</category>
      <category>mysql</category>
      <category>disasterrecovery</category>
    </item>
    <item>
      <title>Patching Critical PHP Vulnerabilities on Ubuntu 24.04: A Production Laravel Walkthrough</title>
      <dc:creator>Michael Oteng</dc:creator>
      <pubDate>Thu, 22 Oct 2026 11:14:10 +0000</pubDate>
      <link>https://dev.to/xetera1/patching-critical-php-vulnerabilities-on-ubuntu-2404-a-production-laravel-walkthrough-1il7</link>
      <guid>https://dev.to/xetera1/patching-critical-php-vulnerabilities-on-ubuntu-2404-a-production-laravel-walkthrough-1il7</guid>
      <description>&lt;p&gt;On 21 October 2026, two critical PHP vulnerabilities, &lt;strong&gt;CVE-2026-109618&lt;/strong&gt; and &lt;strong&gt;CVE-2026-109619&lt;/strong&gt;, prompted a review of the production infrastructure behind &lt;a href="https://swiftvecto.com" rel="noopener noreferrer"&gt;SwiftVecto&lt;/a&gt;, a web-based platform offering browser utilities and file-processing tools.&lt;/p&gt;

&lt;p&gt;The following morning, I carried out security maintenance on the Ubuntu server hosting the application.&lt;/p&gt;

&lt;p&gt;What initially appeared to be a straightforward PHP upgrade turned into a useful reminder about distribution-maintained packages, security backports, backups, and the difference between a successful package installation and a healthy production application.&lt;/p&gt;

&lt;p&gt;This article documents the actual maintenance process, including the commands used, the results obtained, and the checks performed before returning the server to normal operation.&lt;/p&gt;

&lt;p&gt;For security reasons, application-specific directory paths, database names and service identifiers have been replaced with generic examples.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Understanding the vulnerability and the available fix
&lt;/h2&gt;

&lt;p&gt;The two PHP vulnerabilities were addressed through security updates released by the PHP project and Linux distribution maintainers.&lt;/p&gt;

&lt;p&gt;The important question was not simply whether the server was running the latest upstream PHP release.&lt;/p&gt;

&lt;p&gt;It was whether the installed packages contained the security fixes required to address the vulnerabilities.&lt;/p&gt;

&lt;p&gt;This distinction matters because Ubuntu, like other Linux distributions, can backport security fixes into existing package versions.&lt;/p&gt;

&lt;p&gt;For Ubuntu 24.04 LTS, security notice &lt;strong&gt;USN-8948-1&lt;/strong&gt; identified the corrected PHP 8.3 package revision as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;8.3.6-0ubuntu0.24.04.13
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was the version I needed to verify against the production environment.&lt;/p&gt;

&lt;p&gt;Rather than replacing Ubuntu's PHP installation with packages from an alternative repository, the preferred approach was to use the distribution's maintained security packages.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Establishing the production environment
&lt;/h2&gt;

&lt;p&gt;The environment consisted of:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hosting&lt;/td&gt;
&lt;td&gt;DigitalOcean Droplet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operating system&lt;/td&gt;
&lt;td&gt;Ubuntu 24.04.4 LTS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application framework&lt;/td&gt;
&lt;td&gt;Laravel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web server&lt;/td&gt;
&lt;td&gt;Nginx&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PHP runtime&lt;/td&gt;
&lt;td&gt;PHP 8.3-FPM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;MySQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background processing&lt;/td&gt;
&lt;td&gt;Supervisor-managed Laravel queue worker&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I started by checking the operating system and PHP version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /etc/os-release
php &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PHP command reported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PHP 8.3.6 (cli)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At first glance, that looked concerning.&lt;/p&gt;

&lt;p&gt;The upstream PHP project had released newer versions containing the security fixes, while the server was still reporting PHP 8.3.6.&lt;/p&gt;

&lt;p&gt;However, &lt;strong&gt;the upstream version number alone was not sufficient to determine whether the Ubuntu installation was patched&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;There were two reasons.&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;php -v&lt;/code&gt; reports the command-line runtime, which is not necessarily the runtime serving web requests.&lt;/p&gt;

&lt;p&gt;Second, Ubuntu may retain the same upstream PHP version while incorporating backported security fixes.&lt;/p&gt;

&lt;p&gt;The next step was to inspect the actual installed packages.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Checking the installed PHP package revisions
&lt;/h2&gt;

&lt;p&gt;I queried the PHP packages using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dpkg-query &lt;span class="nt"&gt;-W&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'${Package} ${Version}\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'php8.3*'&lt;/span&gt; 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relevant installed packages were at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;8.3.6-0ubuntu0.24.04.12
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This included:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;php8.3-cli&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;php8.3-fpm&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;php8.3-common&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;php8.3-opcache&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ubuntu's security notice identified &lt;code&gt;.13&lt;/code&gt; as the corrected revision.&lt;/p&gt;

&lt;p&gt;The server was running &lt;code&gt;.12&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That established that the relevant security update had not yet been installed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the package revision matters
&lt;/h3&gt;

&lt;p&gt;Consider these two versions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;8.3.6-0ubuntu0.24.04.12
8.3.6-0ubuntu0.24.04.13
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both contain the upstream version number &lt;code&gt;8.3.6&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;However, they are different Ubuntu package revisions.&lt;/p&gt;

&lt;p&gt;The final revision incorporates distribution-specific changes, which can include security backports.&lt;/p&gt;

&lt;p&gt;For administrators maintaining Ubuntu systems, this is why checking only &lt;code&gt;php -v&lt;/code&gt; can be misleading.&lt;/p&gt;

&lt;p&gt;The exact package revision must be compared against the vendor's security advisory.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Refreshing the package indexes
&lt;/h2&gt;

&lt;p&gt;I checked the versions available through APT:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt-cache policy php8.3-cli php8.3-fpm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Initially, the installed and candidate versions were identical:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Installed: 8.3.6-0ubuntu0.24.04.12
Candidate: 8.3.6-0ubuntu0.24.04.12
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The patched revision was not yet visible in the local package indexes.&lt;/p&gt;

&lt;p&gt;I refreshed the indexes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running the policy check again showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Installed: 8.3.6-0ubuntu0.24.04.12
Candidate: 8.3.6-0ubuntu0.24.04.13
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The patched version was now available through the configured Ubuntu repositories.&lt;/p&gt;

&lt;p&gt;There was no need to introduce a third-party repository or migrate to another PHP release branch.&lt;/p&gt;

&lt;p&gt;This kept the maintenance focused on the relevant security update.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Simulating the upgrade before changing production
&lt;/h2&gt;

&lt;p&gt;Before installing anything, I ran an APT simulation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--only-upgrade&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  php8.3-cli &lt;span class="se"&gt;\&lt;/span&gt;
  php8.3-fpm &lt;span class="se"&gt;\&lt;/span&gt;
  php8.3-common &lt;span class="se"&gt;\&lt;/span&gt;
  php8.3-opcache
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;APT reported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;14 upgraded, 0 newly installed,
0 to remove and 69 not upgraded.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proposed changes included the four explicitly requested packages and their related PHP extensions.&lt;/p&gt;

&lt;p&gt;These included components used for MySQL connectivity, XML processing, ZIP handling, image processing and other application functionality.&lt;/p&gt;

&lt;p&gt;All 14 packages were scheduled to move from revision &lt;code&gt;.12&lt;/code&gt; to &lt;code&gt;.13&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The simulation provided two useful assurances:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The update could be resolved without installing additional packages or removing existing ones.&lt;/li&gt;
&lt;li&gt;The proposed changes were limited to the expected PHP packages and extensions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The 69 other packages were outside the scope of this targeted PHP update and would require separate review.&lt;/p&gt;

&lt;p&gt;For production maintenance, I prefer to understand exactly what the package manager intends to change before approving an installation.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Creating backups before applying the patches
&lt;/h2&gt;

&lt;p&gt;Before proceeding, I created a &lt;strong&gt;DigitalOcean Droplet snapshot&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That provided a server-level recovery option if the maintenance introduced an unexpected infrastructure problem.&lt;/p&gt;

&lt;p&gt;However, I also wanted a separate logical database backup.&lt;/p&gt;

&lt;p&gt;The application uses MySQL on the same server, so relying exclusively on a Droplet snapshot would not have been my preferred approach.&lt;/p&gt;

&lt;p&gt;I first verified the database service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl is-active mysql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;active
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I then created a restricted backup directory.&lt;/p&gt;

&lt;p&gt;The path below is illustrative rather than the actual production location:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo install&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 700 /root/app-backups
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A compressed logical database backup can be created using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; pipefail

&lt;span class="nv"&gt;BACKUP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/root/app-backups/database-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d-%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.sql.gz"&lt;/span&gt;

mysqldump &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--single-transaction&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--quick&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-tablespaces&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--routines&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--triggers&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--events&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  app_database | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, &lt;code&gt;app_database&lt;/code&gt; is a placeholder for the actual database name.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;--single-transaction&lt;/code&gt; option is useful for producing a consistent logical backup of InnoDB tables without locking them for the entire dump, provided concurrent schema changes are avoided.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;pipefail&lt;/code&gt; setting ensures that a failure in &lt;code&gt;mysqldump&lt;/code&gt; is not concealed by a successful exit from &lt;code&gt;gzip&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;After creating the backup, I checked the compressed file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also inspected the end of the SQL dump:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;zcat &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual backup contained the MySQL completion marker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-- Dump completed on 2026-10-22  4:28:45
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The compressed backup was approximately 866 KB.&lt;/p&gt;

&lt;p&gt;Restricting access to database backups is also important:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chmod &lt;/span&gt;600 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backup passed the compression integrity check, and the completion marker was present.&lt;/p&gt;

&lt;p&gt;These checks do not replace a proper restoration test, but they provide useful evidence that the backup completed successfully.&lt;/p&gt;

&lt;p&gt;With the Droplet snapshot and database backup available, the update could proceed with a more reasonable recovery position.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Applying the PHP security update
&lt;/h2&gt;

&lt;p&gt;I ran the targeted installation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--only-upgrade&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  php8.3-cli &lt;span class="se"&gt;\&lt;/span&gt;
  php8.3-fpm &lt;span class="se"&gt;\&lt;/span&gt;
  php8.3-common &lt;span class="se"&gt;\&lt;/span&gt;
  php8.3-opcache
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;APT upgraded the expected 14 PHP packages.&lt;/p&gt;

&lt;p&gt;These included:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PHP CLI and PHP-FPM&lt;/li&gt;
&lt;li&gt;PHP common components and OPcache&lt;/li&gt;
&lt;li&gt;MySQL and SQLite extensions&lt;/li&gt;
&lt;li&gt;cURL, GD and internationalisation extensions&lt;/li&gt;
&lt;li&gt;Multibyte string, XML and ZIP extensions&lt;/li&gt;
&lt;li&gt;Other installed PHP 8.3 components&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The installation completed successfully, and PHP-FPM restarted during the update.&lt;/p&gt;

&lt;p&gt;I then checked the installed package revisions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dpkg-query &lt;span class="nt"&gt;-W&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'${Package} ${Version}\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  php8.3-cli &lt;span class="se"&gt;\&lt;/span&gt;
  php8.3-fpm &lt;span class="se"&gt;\&lt;/span&gt;
  php8.3-common &lt;span class="se"&gt;\&lt;/span&gt;
  php8.3-opcache
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All four packages reported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;8.3.6-0ubuntu0.24.04.13
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matched the corrected revision identified in Ubuntu's security notice.&lt;/p&gt;

&lt;p&gt;The PHP vulnerabilities had been addressed through Ubuntu's security-maintained packages without changing the application's PHP major or minor version.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Verifying PHP-FPM and background processing
&lt;/h2&gt;

&lt;p&gt;Installing the packages was only part of the maintenance.&lt;/p&gt;

&lt;p&gt;The next step was to verify that the services required by the application were still functioning.&lt;/p&gt;

&lt;p&gt;I checked PHP-FPM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl status php8.3-fpm &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service reported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Active: active (running)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I reviewed its recent journal entries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; php8.3-fpm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"15 minutes ago"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-pager&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 40
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The inspected output showed normal service restarts during the package installation, with no startup errors.&lt;/p&gt;

&lt;p&gt;The application also uses Laravel queues for background processing.&lt;/p&gt;

&lt;p&gt;These workers are managed by Supervisor, so I checked their status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;supervisorctl status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The production worker was running.&lt;/p&gt;

&lt;p&gt;This is an important check for Laravel applications.&lt;/p&gt;

&lt;p&gt;A website may respond successfully to HTTP requests while queued jobs remain unprocessed because a background worker has stopped.&lt;/p&gt;

&lt;p&gt;Checking the homepage alone would not establish that the application was functioning as expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Checking the website and application logs
&lt;/h2&gt;

&lt;p&gt;I tested the public HTTPS endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'HTTP status: %{http_code}\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://swiftvecto.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP status: 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That confirmed the homepage was responding successfully.&lt;/p&gt;

&lt;p&gt;I also reviewed the Laravel application logs for evidence of new errors following the update.&lt;/p&gt;

&lt;p&gt;For readers adapting this procedure, the application log can be inspected using a command such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 50 /var/www/example-app/storage/logs/laravel.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The path above is an example and should be replaced with the relevant application directory.&lt;/p&gt;

&lt;p&gt;When reviewing logs after maintenance, I find it useful to distinguish between historical errors and errors that began during or after the change window.&lt;/p&gt;

&lt;p&gt;An existing error should not automatically be attributed to the latest update simply because it appears in the log.&lt;/p&gt;

&lt;p&gt;In this case, the inspected log contained no newly recorded production errors attributable to the PHP maintenance.&lt;/p&gt;

&lt;p&gt;That was reassuring, but it was not a substitute for testing individual application features.&lt;/p&gt;

&lt;p&gt;An HTTP 200 response verifies that a particular endpoint responded successfully. It does not prove that every controller, database operation, queued job or file-processing function works correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Infrastructure verification and application regression testing are separate responsibilities.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Identifying a pending kernel update
&lt;/h2&gt;

&lt;p&gt;During the maintenance, Ubuntu also reported that the running kernel was older than the installed kernel.&lt;/p&gt;

&lt;p&gt;The running kernel was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;6.8.0-134-generic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The newer installed kernel was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;6.8.0-147-generic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was separate from the PHP vulnerability remediation.&lt;/p&gt;

&lt;p&gt;However, because the server was already undergoing controlled maintenance, I decided to complete the pending kernel activation.&lt;/p&gt;

&lt;p&gt;Before rebooting, I checked that the essential services were configured to start automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl is-enabled &lt;span class="se"&gt;\&lt;/span&gt;
  nginx php8.3-fpm mysql supervisor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All four returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;enabled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I checked their current running states:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl is-active &lt;span class="se"&gt;\&lt;/span&gt;
  nginx php8.3-fpm mysql supervisor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All four were active.&lt;/p&gt;

&lt;p&gt;I also verified the installed kernel package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dpkg-query &lt;span class="nt"&gt;-W&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'${Package} ${Status} ${Version}\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  linux-image-6.8.0-147-generic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result confirmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;linux-image-6.8.0-147-generic install ok installed 6.8.0-147.147
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, I checked for failed systemd units:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl &lt;span class="nt"&gt;--failed&lt;/span&gt; &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 loaded units listed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the pre-reboot checks completed, I initiated a controlled reboot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reboot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  11. Post-reboot verification
&lt;/h2&gt;

&lt;p&gt;Once the server became available again, I reconnected and verified the active kernel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;6.8.0-147-generic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I checked the essential services:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl is-active &lt;span class="se"&gt;\&lt;/span&gt;
  nginx php8.3-fpm mysql supervisor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All four returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;active
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Supervisor-managed Laravel queue worker had restarted successfully.&lt;/p&gt;

&lt;p&gt;I repeated the public website check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'HTTP status: %{http_code}\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://swiftvecto.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response remained:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP status: 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl &lt;span class="nt"&gt;--failed&lt;/span&gt; &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Again returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 loaded units listed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The new kernel was active, the essential services had recovered, and the public application remained accessible.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. What this maintenance exercise reinforced
&lt;/h2&gt;

&lt;p&gt;There are several lessons worth taking from this process.&lt;/p&gt;

&lt;h3&gt;
  
  
  The upstream version number is not the whole story
&lt;/h3&gt;

&lt;p&gt;PHP 8.3.6 may appear outdated when compared with a newer upstream release.&lt;/p&gt;

&lt;p&gt;On Ubuntu, however, security fixes can be backported into distribution-maintained packages.&lt;/p&gt;

&lt;p&gt;The correct approach is to compare the exact installed package revision against the relevant Ubuntu security notice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Simulate package changes before applying them
&lt;/h3&gt;

&lt;p&gt;APT's simulation mode provides an opportunity to inspect dependency changes, package removals and the overall upgrade scope.&lt;/p&gt;

&lt;p&gt;It is a simple step that can prevent avoidable surprises.&lt;/p&gt;

&lt;h3&gt;
  
  
  Maintain more than one recovery option
&lt;/h3&gt;

&lt;p&gt;A Droplet snapshot and a logical MySQL backup serve different purposes.&lt;/p&gt;

&lt;p&gt;Having both available before modifying production infrastructure improves recovery options if something goes wrong.&lt;/p&gt;

&lt;p&gt;Neither should be considered fully validated without an appropriate restoration test.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check background services as well as HTTP responses
&lt;/h3&gt;

&lt;p&gt;Laravel applications may depend on queue workers, scheduled tasks and other processes that are not exercised by a homepage request.&lt;/p&gt;

&lt;p&gt;Checking Nginx and PHP-FPM is necessary, but it may not be sufficient.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep security maintenance focused
&lt;/h3&gt;

&lt;p&gt;A critical security update is not the ideal time to introduce unrelated application changes.&lt;/p&gt;

&lt;p&gt;The PHP update was handled as a targeted maintenance operation, with unrelated work left for separate review.&lt;/p&gt;

&lt;h3&gt;
  
  
  A successful update is not a complete regression test
&lt;/h3&gt;

&lt;p&gt;A successful package installation, active services, healthy queue workers and an HTTP 200 response are all useful indicators.&lt;/p&gt;

&lt;p&gt;They do not prove that every application feature is functioning correctly.&lt;/p&gt;

&lt;p&gt;That distinction should remain clear when documenting production maintenance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The PHP security update on SwiftVecto's production infrastructure was completed successfully.&lt;/p&gt;

&lt;p&gt;The server moved from Ubuntu's PHP package revision:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;8.3.6-0ubuntu0.24.04.12
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;8.3.6-0ubuntu0.24.04.13
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This addressed the vulnerabilities identified in Ubuntu's security notice.&lt;/p&gt;

&lt;p&gt;Fourteen PHP packages were upgraded, PHP-FPM restarted successfully, and the Laravel queue worker remained operational.&lt;/p&gt;

&lt;p&gt;A pending kernel update was also activated through a controlled reboot.&lt;/p&gt;

&lt;p&gt;Following the reboot, Nginx, PHP-FPM, MySQL and Supervisor were all active, the website returned HTTP 200, and systemd reported no failed services.&lt;/p&gt;

&lt;p&gt;The maintenance did not constitute a complete application regression test or a penetration test, but it provided clear evidence that the required PHP security packages had been installed and that the essential production infrastructure was functioning afterwards.&lt;/p&gt;

&lt;p&gt;For me, the main takeaway is that &lt;strong&gt;security patching is not simply about installing a newer software version. It is about establishing what is affected, verifying the vendor's fix, preparing a recovery path, controlling the change, and checking the system afterwards.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://ubuntu.com/security/notices/USN-8948-1" rel="noopener noreferrer"&gt;Ubuntu Security Notice USN-8948-1&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.php.net/" rel="noopener noreferrer"&gt;PHP official website&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ubuntu.com/security" rel="noopener noreferrer"&gt;Ubuntu security documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>php</category>
      <category>ubuntu</category>
      <category>laravel</category>
      <category>cybersecurity</category>
    </item>
    <item>
      <title>Wi-Fi or VPN sign-in failing after a Windows update? What to check</title>
      <dc:creator>Michael Oteng</dc:creator>
      <pubDate>Fri, 16 Oct 2026 08:24:23 +0000</pubDate>
      <link>https://dev.to/xetera1/wi-fi-or-vpn-sign-in-failing-after-a-windows-update-what-to-check-2596</link>
      <guid>https://dev.to/xetera1/wi-fi-or-vpn-sign-in-failing-after-a-windows-update-what-to-check-2596</guid>
      <description>&lt;p&gt;When a user says their Wi-Fi or VPN stopped working after an update, there are quite a few things to check. Drivers, connection profiles, credentials and the network itself can all come into it.&lt;/p&gt;

&lt;p&gt;One more thing worth checking after the October 2026 Windows updates is how the connection handles automatic sign-in.&lt;/p&gt;

&lt;p&gt;This walkthrough is based on Microsoft’s published guidance. It is a troubleshooting reference, rather than a report of an incident I have personally reproduced.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed?
&lt;/h2&gt;

&lt;p&gt;Microsoft’s 13 October 2026 update changes the default handling of NTLMv1-derived single sign-on credentials from auditing to blocking.&lt;/p&gt;

&lt;p&gt;The documented scope covers Windows 11 24H2, 25H2, 26H2 and Windows Server 2025 where Credential Guard is disabled and &lt;code&gt;BlockNtlmv1SSO&lt;/code&gt; is unset.&lt;/p&gt;

&lt;p&gt;Connections using MS-CHAPv2, including certain Wi-Fi, wired network and VPN configurations, can be affected.&lt;/p&gt;

&lt;p&gt;A useful clue is that automatic sign-in may fail while manually entering credentials still works.&lt;/p&gt;

&lt;p&gt;That gives you a direction to investigate. It does not, on its own, confirm the cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the affected computer
&lt;/h2&gt;

&lt;p&gt;Before changing anything, record:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Windows version and OS build from &lt;code&gt;winver&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The installed update from Windows Update history.&lt;/li&gt;
&lt;li&gt;When the failure started.&lt;/li&gt;
&lt;li&gt;Whether it affects Wi-Fi, wired access, VPN, or more than one.&lt;/li&gt;
&lt;li&gt;Whether manual sign-in behaves differently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I would also compare an affected device with a working one, where possible. Keep the comparison specific: the same connection, similar configuration and the same test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check whether Credential Guard is running
&lt;/h2&gt;

&lt;p&gt;Open &lt;strong&gt;System Information&lt;/strong&gt; by running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;msinfo32
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under &lt;strong&gt;System Summary&lt;/strong&gt;, look for &lt;strong&gt;Virtualization-based security Services Running&lt;/strong&gt; and check whether Credential Guard appears.&lt;/p&gt;

&lt;p&gt;You can also query it from an elevated PowerShell session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$deviceGuard&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Get-CimInstance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;-Namespace&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'root\Microsoft\Windows\DeviceGuard'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;-ClassName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Win32_DeviceGuard&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;-ErrorAction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Stop&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pscustomobject&lt;/span&gt;&lt;span class="p"&gt;]@{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nx"&gt;CredentialGuardRunning&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;$deviceGuard&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SecurityServicesRunning&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;contains&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;True&lt;/code&gt; means the returned running-services list includes Credential Guard.&lt;/p&gt;

&lt;p&gt;If the query fails, treat the result as unknown. A failed query does not prove that the feature is disabled.&lt;/p&gt;

&lt;p&gt;Microsoft documents these verification methods in its &lt;a href="https://learn.microsoft.com/en-us/windows/security/identity-protection/credential-guard/configure" rel="noopener noreferrer"&gt;Credential Guard configuration guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the authentication method
&lt;/h2&gt;

&lt;p&gt;Review the deployed Wi-Fi, wired or VPN profile with whoever manages it. Look specifically for MS-CHAPv2, including PEAP-MSCHAPv2 and EAP-MSCHAPv2.&lt;/p&gt;

&lt;p&gt;Record the actual configuration rather than assuming every device receives the same settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Look for matching events
&lt;/h2&gt;

&lt;p&gt;In Event Viewer, open:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Applications and Services Logs → Microsoft → Windows → NTLM → Operational&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Microsoft identifies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;4024:&lt;/strong&gt; an audited attempt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4025:&lt;/strong&gt; a blocked attempt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This read-only PowerShell query retrieves those events from the last seven days:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-WinEvent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-FilterHashtable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nx"&gt;LogName&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'Microsoft-Windows-NTLM/Operational'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nx"&gt;Id&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4025&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nx"&gt;StartTime&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Get&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;Date&lt;/span&gt;&lt;span class="err"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;AddDays&lt;/span&gt;&lt;span class="err"&gt;(-&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="err"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ErrorAction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Stop&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;Select-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;TimeCreated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Message&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;Format-List&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare the timestamps and event details with the failed connection attempt. An unrelated event elsewhere in the week is not enough to explain the user's problem.&lt;/p&gt;

&lt;p&gt;If no matching events are returned, keep investigating. If access is denied or the log cannot be read, resolve that before interpreting the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about a temporary fix?
&lt;/h2&gt;

&lt;p&gt;Microsoft documents an audit-mode exception, but it reduces protection and should be scoped and temporary. Follow &lt;a href="https://support.microsoft.com/help/5066470" rel="noopener noreferrer"&gt;KB5066470&lt;/a&gt; for the settings and current guidance.&lt;/p&gt;

&lt;p&gt;Before making an exception, record the affected devices, the reason, who owns the remediation and when it will be reviewed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add sign-in to the update test
&lt;/h2&gt;

&lt;p&gt;For a pilot deployment, I would include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Automatic network sign-in after a restart.&lt;/li&gt;
&lt;li&gt;VPN connection and reconnection.&lt;/li&gt;
&lt;li&gt;Access to the internal resources users actually need.&lt;/li&gt;
&lt;li&gt;A check for new authentication errors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is easy to focus on whether an update installed successfully. For the person using the computer, the more useful test is whether they can still connect and get on with their work.&lt;/p&gt;

</description>
      <category>windows</category>
      <category>networking</category>
      <category>security</category>
      <category>powershell</category>
    </item>
    <item>
      <title>What Building a Browser-Based PDF Editor Taught Us About PDF Coordinates, State and Export</title>
      <dc:creator>Michael Oteng</dc:creator>
      <pubDate>Fri, 09 Oct 2026 20:40:08 +0000</pubDate>
      <link>https://dev.to/swiftvecto/what-building-a-browser-based-pdf-editor-taught-us-about-pdf-coordinates-state-and-export-4ogg</link>
      <guid>https://dev.to/swiftvecto/what-building-a-browser-based-pdf-editor-taught-us-about-pdf-coordinates-state-and-export-4ogg</guid>
      <description>&lt;p&gt;Building a PDF editor looks deceptively simple at first.&lt;/p&gt;

&lt;p&gt;Render a PDF in the browser, place some controls around it, let the user add text or images, and then save the result.&lt;/p&gt;

&lt;p&gt;That sounds reasonable until the first object has to survive zooming, page resizing and export.&lt;/p&gt;

&lt;p&gt;While developing the Edit PDF tool for SwiftVecto, we found that the visible editing interface was only a small part of the problem.&lt;/p&gt;

&lt;p&gt;The harder work was underneath it.&lt;/p&gt;

&lt;p&gt;How do you make sure an object placed at a particular position in the browser appears at exactly the same position in the exported PDF?&lt;/p&gt;

&lt;p&gt;How do you represent edits without constantly rewriting the original document?&lt;/p&gt;

&lt;p&gt;What happens when pages have different dimensions?&lt;/p&gt;

&lt;p&gt;What does "redact" actually mean?&lt;/p&gt;

&lt;p&gt;And when should an operation happen in the browser rather than on the server?&lt;/p&gt;

&lt;p&gt;These turned out to be some of the most interesting engineering problems in the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The screen is not the PDF
&lt;/h2&gt;

&lt;p&gt;One of the first problems is coordinate systems.&lt;/p&gt;

&lt;p&gt;Browser interfaces normally work from the top-left:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0,0 ─────────────→ X
 │
 │
 ↓
 Y
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PDF coordinate systems traditionally work from the bottom-left:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Y
 ↑
 │
 │
0,0 ─────────────→ X
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Already, an object's Y position cannot simply be copied from the browser into the PDF.&lt;/p&gt;

&lt;p&gt;But that is only the beginning.&lt;/p&gt;

&lt;p&gt;Now introduce:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;zoom&lt;/li&gt;
&lt;li&gt;page rotation&lt;/li&gt;
&lt;li&gt;crop boxes&lt;/li&gt;
&lt;li&gt;media boxes&lt;/li&gt;
&lt;li&gt;different page dimensions&lt;/li&gt;
&lt;li&gt;device pixel ratios&lt;/li&gt;
&lt;li&gt;responsive editor layouts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A text object that appears at &lt;code&gt;(300, 420)&lt;/code&gt; on the screen does not necessarily belong at &lt;code&gt;(300, 420)&lt;/code&gt; in the PDF.&lt;/p&gt;

&lt;p&gt;This becomes especially obvious when zooming.&lt;/p&gt;

&lt;p&gt;Suppose the user positions an object while viewing a page at 80% zoom. If its stored coordinates are based on screen pixels, changing the zoom can effectively change the document.&lt;/p&gt;

&lt;p&gt;That is the wrong model.&lt;/p&gt;

&lt;p&gt;Zoom should change the &lt;strong&gt;view&lt;/strong&gt;, not the underlying document state.&lt;/p&gt;

&lt;p&gt;A much safer architecture is to treat coordinate conversion as its own subsystem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PDF coordinates
      ↕
Editor coordinates
      ↕
Screen coordinates
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Features then use the same transformation layer rather than implementing their own coordinate calculations.&lt;/p&gt;

&lt;p&gt;That becomes increasingly important as the editor grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Don't rewrite the PDF after every edit
&lt;/h2&gt;

&lt;p&gt;Another important decision was how to represent the document while it is being edited.&lt;/p&gt;

&lt;p&gt;The tempting approach is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User adds text
      ↓
Modify PDF

User adds image
      ↓
Modify PDF again

User moves object
      ↓
Modify PDF again
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That quickly becomes difficult to manage.&lt;/p&gt;

&lt;p&gt;Instead, we found it much more useful to think of the editor as maintaining its own document state.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Original PDF
     │
     ├── Original pages
     │
     └── Editor state
           ├── Page operations
           ├── Text objects
           ├── Images
           ├── Drawings
           ├── Annotations
           ├── Links
           ├── Forms
           └── Redactions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An editor object can then have a structure along these lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;obj_82&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;pageId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;page_1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="nx"&gt;bounds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;140&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;215&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;

    &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SwiftVecto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;fontFamily&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Helvetica&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;bold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;italic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#111827&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is not the exact schema.&lt;/p&gt;

&lt;p&gt;It is the separation between the &lt;strong&gt;source document&lt;/strong&gt; and the &lt;strong&gt;editing state&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That separation makes features such as moving objects, undo/redo, deleting objects and changing properties considerably easier to reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Stable page identity matters
&lt;/h2&gt;

&lt;p&gt;Page management introduces another interesting problem.&lt;/p&gt;

&lt;p&gt;Imagine a five-page document.&lt;/p&gt;

&lt;p&gt;The user adds something to page 3.&lt;/p&gt;

&lt;p&gt;Then they move page 3 to the beginning of the document.&lt;/p&gt;

&lt;p&gt;Is the object attached to page 3, or to the page that is now page 1?&lt;/p&gt;

&lt;p&gt;If objects are associated only with page numbers, reordering becomes dangerous.&lt;/p&gt;

&lt;p&gt;A better model gives each page a stable internal identity.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;page_a81f&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;sourcePage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;rotation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;deleted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The visible page number can change.&lt;/p&gt;

&lt;p&gt;The page identity does not.&lt;/p&gt;

&lt;p&gt;That distinction makes page reordering much easier to manage because objects belong to a page entity rather than to a temporary position in an array.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Added content and existing content are different problems
&lt;/h2&gt;

&lt;p&gt;Adding new text to a PDF and editing text that already exists in the PDF may look like the same operation to a user.&lt;/p&gt;

&lt;p&gt;Technically, they are very different.&lt;/p&gt;

&lt;p&gt;Adding new text can often be represented as a new editor object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Page
 + text object
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Existing PDF text may instead be represented internally as positioned glyphs, text runs, font references and drawing instructions.&lt;/p&gt;

&lt;p&gt;There may not be anything resembling the editable paragraph you would find in a word processor.&lt;/p&gt;

&lt;p&gt;This is one reason PDF editing becomes significantly more complicated once an editor moves beyond adding objects and starts modifying the document's existing content.&lt;/p&gt;

&lt;p&gt;The UI may make both operations look similar.&lt;/p&gt;

&lt;p&gt;The document model underneath them is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Redaction is not a black rectangle
&lt;/h2&gt;

&lt;p&gt;This was one of the most important distinctions in the editor.&lt;/p&gt;

&lt;p&gt;Visually covering information is not necessarily redacting it.&lt;/p&gt;

&lt;p&gt;If sensitive text remains inside the PDF and a black rectangle is simply drawn over it, the underlying information may still exist.&lt;/p&gt;

&lt;p&gt;Depending on how the PDF is constructed, it might still be extractable, searchable or recoverable.&lt;/p&gt;

&lt;p&gt;A real redaction workflow therefore needs to treat redaction differently from ordinary drawing.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Select sensitive region
        ↓
Identify affected content
        ↓
Remove or irreversibly alter that content
        ↓
Apply the visible redaction appearance
        ↓
Generate and validate the resulting PDF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is fundamentally different from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Draw black rectangle
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two may look identical on screen.&lt;/p&gt;

&lt;p&gt;From a document-security perspective, they are not remotely equivalent.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. OCR is another separate layer
&lt;/h2&gt;

&lt;p&gt;OCR creates a similar distinction.&lt;/p&gt;

&lt;p&gt;A scanned PDF may contain pages that are effectively just images.&lt;/p&gt;

&lt;p&gt;A human can read the text.&lt;/p&gt;

&lt;p&gt;The PDF cannot necessarily search or select it.&lt;/p&gt;

&lt;p&gt;OCR can identify characters in those images, but an editor then has to decide what to do with that information.&lt;/p&gt;

&lt;p&gt;Possible outcomes include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;extracted text&lt;/li&gt;
&lt;li&gt;searchable text layers&lt;/li&gt;
&lt;li&gt;selectable text&lt;/li&gt;
&lt;li&gt;positioning data&lt;/li&gt;
&lt;li&gt;confidence information&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So "run OCR" is not really a single UI operation.&lt;/p&gt;

&lt;p&gt;It is a document-processing pipeline.&lt;/p&gt;

&lt;p&gt;That affects where the feature belongs architecturally.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Not everything belongs in the browser
&lt;/h2&gt;

&lt;p&gt;We wanted the editor to feel immediate, so interactive operations naturally belong close to the user.&lt;/p&gt;

&lt;p&gt;Things such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;rendering&lt;/li&gt;
&lt;li&gt;selection&lt;/li&gt;
&lt;li&gt;dragging&lt;/li&gt;
&lt;li&gt;resizing&lt;/li&gt;
&lt;li&gt;drawing&lt;/li&gt;
&lt;li&gt;annotations&lt;/li&gt;
&lt;li&gt;signatures&lt;/li&gt;
&lt;li&gt;page thumbnails&lt;/li&gt;
&lt;li&gt;reordering&lt;/li&gt;
&lt;li&gt;zoom&lt;/li&gt;
&lt;li&gt;undo and redo&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;are well suited to browser-side interaction.&lt;/p&gt;

&lt;p&gt;But some operations have different requirements.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OCR&lt;/li&gt;
&lt;li&gt;true redaction&lt;/li&gt;
&lt;li&gt;document sanitisation&lt;/li&gt;
&lt;li&gt;complex content removal&lt;/li&gt;
&lt;li&gt;PDF repair or normalisation&lt;/li&gt;
&lt;li&gt;final output validation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;may require heavier or more controlled processing.&lt;/p&gt;

&lt;p&gt;For SwiftVecto, this led to a hybrid model.&lt;/p&gt;

&lt;p&gt;The browser owns the interactive editing experience.&lt;/p&gt;

&lt;p&gt;The processing layer handles operations where heavier processing or output correctness becomes more important.&lt;/p&gt;

&lt;p&gt;This also means privacy claims need to be precise.&lt;/p&gt;

&lt;p&gt;It would be misleading to say that every PDF operation always happens locally if some features legitimately require server-side processing.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Export is where everything has to agree
&lt;/h2&gt;

&lt;p&gt;An editor can look perfect in the browser and still produce a bad PDF.&lt;/p&gt;

&lt;p&gt;Export is where all the architectural decisions finally meet.&lt;/p&gt;

&lt;p&gt;Conceptually, our pipeline looks more like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Original PDF
      +
Editor state
      ↓
Apply page operations
      ↓
Apply content changes
      ↓
Apply annotations
      ↓
Apply images, text and signatures
      ↓
Apply forms
      ↓
Apply redactions
      ↓
Validate output
      ↓
Final PDF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the coordinate model is wrong, export exposes it.&lt;/p&gt;

&lt;p&gt;If page identity is wrong, export exposes it.&lt;/p&gt;

&lt;p&gt;If zoom has leaked into document coordinates, export exposes it.&lt;/p&gt;

&lt;p&gt;If reordered pages have lost their objects, export exposes it.&lt;/p&gt;

&lt;p&gt;This is why treating export as an afterthought is risky.&lt;/p&gt;

&lt;p&gt;The editor and export pipeline need to share the same understanding of the document.&lt;/p&gt;

&lt;h2&gt;
  
  
  The biggest lesson
&lt;/h2&gt;

&lt;p&gt;The biggest lesson from building the editor has been that a PDF editor is not primarily a toolbar.&lt;/p&gt;

&lt;p&gt;The toolbar is simply the visible interface to a much larger document system.&lt;/p&gt;

&lt;p&gt;Behind buttons such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Text
Image
Sign
Draw
Shapes
Annotate
Link
Forms
Redact
Crop
OCR
Pages
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;are several different engineering problems involving document state, coordinate transformation, page identity, rendering, processing and export.&lt;/p&gt;

&lt;p&gt;Once those concerns are separated properly, adding features becomes much more manageable.&lt;/p&gt;

&lt;p&gt;Without those boundaries, every new feature risks becoming another special case.&lt;/p&gt;

&lt;p&gt;We wrote a more detailed breakdown of the architecture and the problems we encountered in the original SwiftVecto guide:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://swiftvecto.com/guides/building-a-browser-based-pdf-editor" rel="noopener noreferrer"&gt;https://swiftvecto.com/guides/building-a-browser-based-pdf-editor&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The browser-based editor discussed here is also available to try on SwiftVecto:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://swiftvecto.com/tool/edit-pdf" rel="noopener noreferrer"&gt;https://swiftvecto.com/tool/edit-pdf&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>pdf</category>
      <category>programming</category>
    </item>
    <item>
      <title>Formatting Laravel Blade Templates: Why It Is Harder Than Formatting HTML</title>
      <dc:creator>Michael Oteng</dc:creator>
      <pubDate>Sat, 12 Sep 2026 14:34:11 +0000</pubDate>
      <link>https://dev.to/swiftvecto/formatting-laravel-blade-templates-why-it-is-harder-than-formatting-html-5h4h</link>
      <guid>https://dev.to/swiftvecto/formatting-laravel-blade-templates-why-it-is-harder-than-formatting-html-5h4h</guid>
      <description>&lt;p&gt;Laravel Blade templates can look deceptively simple.&lt;/p&gt;

&lt;p&gt;At first glance, a &lt;code&gt;.blade.php&lt;/code&gt; file is mostly HTML. That can make formatting one seem like the same problem as formatting an ordinary HTML document.&lt;/p&gt;

&lt;p&gt;It isn't.&lt;/p&gt;

&lt;p&gt;A Blade template can combine HTML with conditional directives, loops, components, slots, PHP expressions and Laravel-specific syntax, sometimes within the same few lines.&lt;/p&gt;

&lt;p&gt;Consider a deliberately badly formatted example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;div class="user-card"&amp;gt;
@if($user)
@if($user-&amp;gt;active)
&amp;lt;h2&amp;gt;{{ $user-&amp;gt;name }}&amp;lt;/h2&amp;gt;
&amp;lt;p&amp;gt;{{ $user-&amp;gt;email }}&amp;lt;/p&amp;gt;
@if($user-&amp;gt;isAdmin())
&amp;lt;span class="badge"&amp;gt;Administrator&amp;lt;/span&amp;gt;
@elseif($user-&amp;gt;isEditor())
&amp;lt;span class="badge"&amp;gt;Editor&amp;lt;/span&amp;gt;
@else
&amp;lt;span class="badge"&amp;gt;User&amp;lt;/span&amp;gt;
@endif
@else
&amp;lt;p&amp;gt;This account is inactive.&amp;lt;/p&amp;gt;
@endif
@else
&amp;lt;p&amp;gt;No user is available.&amp;lt;/p&amp;gt;
@endif
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HTML itself is not particularly complicated. The difficulty comes from understanding the structure created by the Blade directives around it.&lt;/p&gt;

&lt;p&gt;A formatter needs to recognise that &lt;code&gt;@if&lt;/code&gt;, &lt;code&gt;@elseif&lt;/code&gt;, &lt;code&gt;@else&lt;/code&gt; and &lt;code&gt;@endif&lt;/code&gt; are related control structures. It needs to understand how the HTML is nested inside those structures while also preserving Blade expressions such as &lt;code&gt;{{ $user-&amp;gt;name }}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Simply treating the file as HTML is therefore not enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Blade-aware formatter needs to understand
&lt;/h2&gt;

&lt;p&gt;Formatting HTML is largely concerned with the hierarchy of elements.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;section&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Hello&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/section&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The opening and closing tags provide most of the information needed to understand the structure.&lt;/p&gt;

&lt;p&gt;Blade introduces another structural layer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@if ($user)
    &amp;lt;div&amp;gt;
        @foreach ($user-&amp;gt;notifications as $notification)
            &amp;lt;p&amp;gt;{{ $notification-&amp;gt;message }}&amp;lt;/p&amp;gt;
        @endforeach
    &amp;lt;/div&amp;gt;
@endif
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are now at least three things a formatter needs to reason about:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The HTML structure.&lt;/li&gt;
&lt;li&gt;The Blade directive structure.&lt;/li&gt;
&lt;li&gt;The expressions embedded within the template.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Real templates can become considerably more complicated when components, slots, conditional attributes, sections and other Blade features are introduced.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;x-layout&amp;gt;
    &amp;lt;x-slot:title&amp;gt;
        {{ $pageTitle }}
    &amp;lt;/x-slot:title&amp;gt;

    @if ($user-&amp;gt;isAdmin())
        &amp;lt;x-admin.dashboard :user="$user" /&amp;gt;
    @else
        &amp;lt;x-user.dashboard :user="$user" /&amp;gt;
    @endif
&amp;lt;/x-layout&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A formatter that only understands HTML does not have a complete picture of that document.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should our badly formatted example become?
&lt;/h2&gt;

&lt;p&gt;Running the original example through a Blade-aware formatter produces a much clearer structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;div class="user-card"&amp;gt;
    @if ($user)
        @if ($user-&amp;gt;active)
            &amp;lt;h2&amp;gt;{{ $user-&amp;gt;name }}&amp;lt;/h2&amp;gt;
            &amp;lt;p&amp;gt;{{ $user-&amp;gt;email }}&amp;lt;/p&amp;gt;
            @if ($user-&amp;gt;isAdmin())
                &amp;lt;span class="badge"&amp;gt;Administrator&amp;lt;/span&amp;gt;
            @elseif ($user-&amp;gt;isEditor())
                &amp;lt;span class="badge"&amp;gt;Editor&amp;lt;/span&amp;gt;
            @else
                &amp;lt;span class="badge"&amp;gt;User&amp;lt;/span&amp;gt;
            @endif
        @else
            &amp;lt;p&amp;gt;This account is inactive.&amp;lt;/p&amp;gt;
        @endif
    @else
        &amp;lt;p&amp;gt;No user is available.&amp;lt;/p&amp;gt;
    @endif
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing particularly exciting happened to the application logic.&lt;/p&gt;

&lt;p&gt;That's the point.&lt;/p&gt;

&lt;p&gt;Formatting isn't supposed to redesign the template. It makes its existing structure easier for a human to see.&lt;/p&gt;

&lt;p&gt;The relationship between the outer user check, the account status check and the role check is now immediately apparent.&lt;/p&gt;

&lt;p&gt;There is also a smaller change that is easy to overlook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@if($user)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;became:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@if ($user)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Consistent spacing and indentation might seem cosmetic, but across a large collection of views they reduce unnecessary variation and make templates easier to scan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Formatting is not linting
&lt;/h2&gt;

&lt;p&gt;This distinction is important.&lt;/p&gt;

&lt;p&gt;A formatter can make code consistent without determining whether that code represents a good implementation.&lt;/p&gt;

&lt;p&gt;For example, this can be beautifully formatted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@if ($conditionOne)
    @if ($conditionTwo)
        @if ($conditionThree)
            @if ($conditionFour)
                ...
            @endif
        @endif
    @endif
@endif
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But formatting does not answer whether four levels of nested conditions belong in the template.&lt;/p&gt;

&lt;p&gt;Perhaps some of that logic should move elsewhere. Perhaps a Blade component would make the view clearer. Perhaps the underlying design needs reconsideration.&lt;/p&gt;

&lt;p&gt;Similarly, a formatter cannot necessarily tell you that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a route name does not exist;&lt;/li&gt;
&lt;li&gt;a variable was never passed to the view;&lt;/li&gt;
&lt;li&gt;a component is missing;&lt;/li&gt;
&lt;li&gt;an included view does not exist;&lt;/li&gt;
&lt;li&gt;the business logic is incorrect.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Formatting improves presentation. It should not be confused with validation, static analysis, testing or application design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blade formatting options
&lt;/h2&gt;

&lt;p&gt;There are several ways to approach Blade formatting.&lt;/p&gt;

&lt;p&gt;Editor extensions can format templates as part of the development workflow. Blade-aware Prettier tooling can provide automated formatting, and dedicated Blade formatter projects are available for developers who want formatting integrated into their local environment.&lt;/p&gt;

&lt;p&gt;Laravel Pint also supports Blade formatting.&lt;/p&gt;

&lt;p&gt;Blade formatting is not enabled by default in Pint, but it can be invoked with the &lt;code&gt;--blade&lt;/code&gt; option or configured using the &lt;code&gt;Pint/laravel_blade&lt;/code&gt; rule.&lt;/p&gt;

&lt;p&gt;For a Laravel project where Pint is already part of the development workflow, that can be a natural place to enforce formatting consistently.&lt;/p&gt;

&lt;p&gt;There are also situations where you may not want to configure anything locally.&lt;/p&gt;

&lt;p&gt;Perhaps you have been sent a Blade fragment.&lt;/p&gt;

&lt;p&gt;Perhaps you are reviewing an example outside the original project.&lt;/p&gt;

&lt;p&gt;Perhaps you are working on a machine without the project's development environment.&lt;/p&gt;

&lt;p&gt;Or perhaps you simply want to paste a template somewhere, format it and copy the result.&lt;/p&gt;

&lt;p&gt;That is where a browser-based formatter can be useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building a browser-based Blade formatter
&lt;/h2&gt;

&lt;p&gt;While developing SwiftVecto, I wanted the Laravel Blade Formatter to work without requiring the user to upload a Blade file or configure a Laravel project.&lt;/p&gt;

&lt;p&gt;The formatter therefore operates directly in the browser using Blade-aware formatting tooling.&lt;/p&gt;

&lt;p&gt;The basic workflow is intentionally simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Blade source
     ↓
Blade-aware formatter
     ↓
Formatted template
     ↓
Copy result
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tool also analyses the source separately to provide information such as directive counts, echo expressions, components, slots, loops, conditionals, sections and approximate directive nesting depth.&lt;/p&gt;

&lt;p&gt;Those statistics aren't part of formatting itself. They are simply another way of getting a quick picture of the structure of a template.&lt;/p&gt;

&lt;p&gt;The important architectural distinction is that normal interactive formatting is performed in the browser rather than sending the Blade source to the server for processing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating reusable formatting fixtures
&lt;/h2&gt;

&lt;p&gt;While testing formatting behaviour, another useful idea emerged.&lt;/p&gt;

&lt;p&gt;Instead of keeping test examples buried inside development notes, why not make them reusable?&lt;/p&gt;

&lt;p&gt;I created a public SwiftVecto repository for Blade formatting examples and fixtures.&lt;/p&gt;

&lt;p&gt;A simple fixture structure can contain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fixtures/
├── unformatted/
│   └── conditionals.blade.php
└── expected/
    └── conditionals.blade.php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unformatted version provides a reproducible input.&lt;/p&gt;

&lt;p&gt;The expected version provides a reference result.&lt;/p&gt;

&lt;p&gt;That makes it easier to experiment with different formatting workflows and, as the fixture collection grows, test more interesting Blade structures.&lt;/p&gt;

&lt;p&gt;The expected output should not necessarily be treated as the one canonical way every Blade template must look. Formatter versions, configuration and formatting rules can legitimately produce different results.&lt;/p&gt;

&lt;p&gt;The important part is having reproducible examples.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the example yourself
&lt;/h2&gt;

&lt;p&gt;The example used in this article is available as part of the public Blade formatting fixtures:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/SwiftVecto/blade-formatting-examples" rel="noopener noreferrer"&gt;View the Blade formatting examples on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can also paste the unformatted example directly into the browser-based formatter:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://swiftvecto.com/tool/laravel-blade-formatter" rel="noopener noreferrer"&gt;Try the SwiftVecto Laravel Blade Formatter&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No Laravel project is required to experiment with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;Formatting will not make a Blade template well designed.&lt;/p&gt;

&lt;p&gt;It will not fix application logic, replace testing or tell you whether a view has become too complicated.&lt;/p&gt;

&lt;p&gt;What it can do is remove unnecessary visual inconsistency and make the structure that already exists considerably easier to reason about.&lt;/p&gt;

&lt;p&gt;And with Blade, that structure involves more than HTML.&lt;/p&gt;

&lt;p&gt;As the public fixture collection grows, I plan to add examples covering components, slots, loops, layouts, forms, convenience directives and some of the more awkward structures that can appear in real Blade templates.&lt;/p&gt;

&lt;p&gt;If you have an interesting Blade formatting edge case, I'd be interested to see it.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
