<?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: giannis</title>
    <description>The latest articles on DEV Community by giannis (@giannis_chatz).</description>
    <link>https://dev.to/giannis_chatz</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%2F4239911%2F6f25aa99-3acb-418e-ab8b-7d4c50257326.png</url>
      <title>DEV Community: giannis</title>
      <link>https://dev.to/giannis_chatz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/giannis_chatz"/>
    <language>en</language>
    <item>
      <title>From a one-off script to a SaaS: notes on multi-sheet Excel nested XSD</title>
      <dc:creator>giannis</dc:creator>
      <pubDate>Wed, 11 Nov 2026 22:23:57 +0000</pubDate>
      <link>https://dev.to/giannis_chatz/from-a-one-off-script-to-a-saas-notes-on-multi-sheet-excel-nested-xsd-b7h</link>
      <guid>https://dev.to/giannis_chatz/from-a-one-off-script-to-a-saas-notes-on-multi-sheet-excel-nested-xsd-b7h</guid>
      <description>&lt;p&gt;So a few months ago I was handed a thing at work. My job is junior web&lt;br&gt;
dev, mostly Laravel and Drupal, and one day a ticket landed in my&lt;br&gt;
queue that said: convert this Excel files into XMLs, validate it against&lt;br&gt;
this XSDs, submit it to the EIT portal for grant reporting.&lt;/p&gt;

&lt;p&gt;I thought — Excel to XML, cool, I've done CSVs before, how different can&lt;br&gt;
it be.&lt;/p&gt;

&lt;p&gt;Then I opened the XSD, it wasn't a flat thing.&lt;br&gt;
The schema didn't describe rows. It grouped data under several distinct&lt;br&gt;
entity types — projects, partners, deliverables, KPIs — each with its&lt;br&gt;
own nested shape and its own required fields. And the Excel template&lt;br&gt;
reflected that: one sheet per entity type, with headers that mostly&lt;br&gt;
matched the XSD field names, except where the person who built the&lt;br&gt;
template had used slightly different wording, or added trailing spaces.&lt;/p&gt;

&lt;p&gt;So the real task wasn't "convert rows to XML". It was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Parse N sheets from one workbook&lt;/li&gt;
&lt;li&gt;Figure out which XSD entity each sheet corresponds to&lt;/li&gt;
&lt;li&gt;Walk the XSD to learn the nested structure&lt;/li&gt;
&lt;li&gt;Compose one XML tree from all the sheets together&lt;/li&gt;
&lt;li&gt;Validate the whole thing before anyone had to hit submit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PhpSpreadsheet handled step 1 beautifully. Everything else was me,&lt;br&gt;
var_dumps, and some AI assistance on the parts I couldn't figure out&lt;br&gt;
alone.&lt;/p&gt;
&lt;h2&gt;
  
  
  Walking the XSD instead of hard-coding it
&lt;/h2&gt;

&lt;p&gt;The temptation, honestly, was to hard-code everything. Look at the XSD&lt;br&gt;
once, write a &lt;code&gt;transform()&lt;/code&gt; function full of assumptions, ship it.&lt;br&gt;
I did that first. It worked. Then the XSD updated and broke every&lt;br&gt;
assumption.&lt;/p&gt;

&lt;p&gt;The second version read the XSD instead:&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;$dom&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;DOMDocument&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;span class="nv"&gt;$dom&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$xsdPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$xpath&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;DOMXPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dom&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$xpath&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;registerNamespace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'xs'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'http://www.w3.org/2001/XMLSchema'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$entities&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$xpath&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'/xs:schema/xs:element//xs:complexType/xs:sequence/xs:element'&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$entities&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$typeRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// Recurse into the type to learn its shape&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the XSD changed again (and it did, several times), I didn't touch the PHP. The script read the new schema and adapted. Smug moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mapping sheets to entities, which is actually the hard part
&lt;/h2&gt;

&lt;p&gt;Most Excel-to-XML tutorials wave their hands here. The reality: sheet&lt;br&gt;
names almost never match XSD entity names cleanly. "Partners" vs&lt;br&gt;
"Partner_Organizations". "KPIs" vs "Project KPIs (KIC Level)".&lt;/p&gt;

&lt;p&gt;What worked was embarrassingly simple:&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="k"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;matchSheetToEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$sheetName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$entityNames&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/[^a-z0-9]/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$sheetName&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;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$entityNames&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$entityNorm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/[^a-z0-9]/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$entity&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;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$normalized&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$entityNorm&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$entity&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;span class="nv"&gt;$best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'entity'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'score'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$entityNames&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;similar_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$normalized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$pct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$pct&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$best&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'score'&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;span class="nv"&gt;$best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'entity'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'score'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$pct&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;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$best&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'score'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$best&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'entity'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&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;Not beautiful. But it got around 80% right on the first pass, and I&lt;br&gt;
surfaced the other 20% as explicit "please confirm this mapping"&lt;br&gt;
prompts. Which, it turns out, people prefer to a tool that silently&lt;br&gt;
gets it wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  The validation step is actually the point
&lt;/h2&gt;

&lt;p&gt;Generating XML is a solved problem. Generating XML that passes a&lt;br&gt;
real-world XSD on the first try is a different sport. Required fields,&lt;br&gt;
enum constraints, xs:choice branches, minOccurs/maxOccurs rules —&lt;br&gt;
the schema rejects you for reasons you didn't know existed until it did.&lt;/p&gt;

&lt;p&gt;PHP's DOMDocument::schemaValidate() does the work, and&lt;br&gt;
libxml_get_errors() returns per-line details. The useful move is&lt;br&gt;
translating those errors back into Excel coordinates:&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="nf"&gt;libxml_use_internal_errors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$doc&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;DOMDocument&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;span class="nv"&gt;$doc&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;loadXML&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$generatedXml&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$doc&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;schemaValidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$xsdPath&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;span class="nv"&gt;$errors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;libxml_get_errors&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;span class="nf"&gt;libxml_clear_errors&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;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$errors&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$cellRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;xmlLineToCell&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$error&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$report&lt;/span&gt;&lt;span class="p"&gt;[&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="s1"&gt;'cell'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$cellRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'rule'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$error&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;message&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;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;"Row 47 of the Projects sheet, column 'Budget': expected positive&lt;br&gt;
decimal, got 'TBD'" is a wildly more useful error message than "schema&lt;br&gt;
validation failed". That one UX detail probably saved more rejected&lt;br&gt;
submissions than any other part of the whole thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I realized something
&lt;/h2&gt;

&lt;p&gt;The script worked. I shipped it. I moved on.&lt;/p&gt;

&lt;p&gt;A couple of weeks later a friend mentioned in passing he was dealing&lt;br&gt;
with Peppol invoice generation at his job. He described the same shape&lt;br&gt;
of problem: multi-sheet Excel template, strict XSD, nested entities,&lt;br&gt;
portal that rejected anything off-spec. Different domain, identical&lt;br&gt;
pain.&lt;/p&gt;

&lt;p&gt;That's when it clicked. The EIT thing wasn't an EIT thing. It was a&lt;br&gt;
category:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Peppol invoice implementers across the EU&lt;/li&gt;
&lt;li&gt;FatturaPA and ZUGFeRD compliance in Italy and Germany&lt;/li&gt;
&lt;li&gt;In-house ERP-to-partner-XML pipes at basically any B2B company&lt;/li&gt;
&lt;li&gt;Grant reporting at research orgs and NGOs&lt;/li&gt;
&lt;li&gt;Tax and customs filings everywhere&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All the same underlying problem: someone with a specific XSD and messy&lt;br&gt;
Excel data, trying to produce valid XML, with usable errors when it&lt;br&gt;
fails.&lt;/p&gt;

&lt;p&gt;So I spent some nights and weekends refactoring the one-off script into&lt;br&gt;
something that could accept any XSD, from any user. The main&lt;br&gt;
changes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hard-coded schema → users upload their own XSD&lt;/li&gt;
&lt;li&gt;Single-use parser → saved mapping templates you can reuse next cycle&lt;/li&gt;
&lt;li&gt;Trusted local script → multi-tenant with per-user encryption, because
people are going to upload real invoice data&lt;/li&gt;
&lt;li&gt;"XML output" → "XML output + a per-row error report" (which, again,
is secretly the point)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's now a small SaaS called &lt;a href="https://nichexml.com" rel="noopener noreferrer"&gt;NicheXML&lt;/a&gt;. Free&lt;br&gt;
tier, no credit card, zero paying customers yet because I only launched&lt;br&gt;
it a couple of weeks ago. If you've built similar pipes, I'd genuinely&lt;br&gt;
like to compare notes on the mapping UX — that's the part I'm least&lt;br&gt;
sure about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I'd tell past-me
&lt;/h2&gt;

&lt;p&gt;A few things I wish someone had said to me when I started the EIT thing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A one-off is often a product you haven't labeled yet. If somebody
handed you a specific schema, somebody else got handed a different
one that works the same way. You're not that unique.&lt;/li&gt;
&lt;li&gt;Let the XSD drive the code. Hard-coding the schema feels faster
for exactly one afternoon and then costs you forever.&lt;/li&gt;
&lt;li&gt;"80% auto, 20% ask" beats "100% auto, 20% wrong." Users forgive
a prompt. They remember when you silently mapped the wrong column.&lt;/li&gt;
&lt;li&gt;Error messages are the product. "Row 47 is wrong because of X"
gets remembered. "XML validation failed" is cursed.&lt;/li&gt;
&lt;li&gt;If you're holding other people's regulated data, encrypt it per
tenant before you store it. B2B invoicing data is not nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thanks for reading. If you've worked on anything similar — especially&lt;br&gt;
the sheet-to-entity mapping part — hit me in the comments, I'd love to&lt;br&gt;
compare notes.&lt;/p&gt;




&lt;p&gt;Built with Laravel 12, PHP 8.3, PhpSpreadsheet, MySQL, Redis. Hosted&lt;br&gt;
on a Hetzner.&lt;/p&gt;




</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
