DEV Community

Matt
Matt

Posted on

Hand-researched boat specs as JSON, applied by slug in a seeder

I built a CLC Northeaster Dory in my garage in 2020. The hardest part of choosing it was not the woodworking. It was finding honest numbers: how long the boat really is, what it is built from, and how many hours it took people like me. That is why I built Boat Build Log, a Laravel 13 and MySQL 8 site where amateur builders log their builds and every design gets a page with real medians from those logs.

The logs need a catalog of designs to hang on. It has 1,052 design pages from dozens of designers, plus a few hundred production boats for people who restore rather than build. This post is about how the specs and write-ups for all of those got into the database, and how they stay there.

The problem

The catalog started as plain PHP arrays in database/seeders/data, one file per vendor, crawled from the designers' own listings. Each vendor publishes differently, so the files have different shapes. CLC gives a beam, Glen-L mostly does not, Devlin has his own format, and so on. BoatModelSeeder has a loader per shape and merges them:

$rows = array_merge(
    $this->fromVendorFile('clc.php', 'Chesapeake Light Craft', kitAvailable: true, withBeam: true),
    $this->fromVendorFile('glen-l.php', 'Glen-L Marine', designer: 'Glen-L Marine'),
    $this->fromDevlinFile(),
    $this->fromBowdidgeFile(),
    // ...a dozen more
);
Enter fullscreen mode Exit fullscreen mode

Most rows came out as a name and a category. No length, no construction method, and a one-line description the seeder composes from whatever it knows ("17 ft rowing design from X. Plans and patterns are sold by the designer."). Those pages said nothing a searcher could use, so I kept them out of the index.

The fix was research by hand: look up every design on its designer's own site, write two paragraphs in my own words (never their marketing copy), and record LOA, beam, construction and the deep link to that design's plans page.

For Glen-L I did it the obvious way, by editing glen-l.php and a big descriptions.php keyed on brand and name. That worked for one file with one shape. It did not scale to the rest, for two reasons:

  1. Nine loaders, nine row shapes. Adding columns to each would have meant touching every loader.
  2. A good number of rows were never in a seed file at all. Builders can propose a design that is missing, and once approved it lives only in the database. There was no PHP array to edit.

Why per-designer JSON keyed by slug

Every row has a slug, whether it came from a seed file or a builder's proposal. Slugs are brand-scoped because Glen-L and CLC both sell a boat called Dory:

$slug = Str::slug(trim(($row['brand'] ?? '').' '.$row['name']));
Enter fullscreen mode Exit fullscreen mode

So the research layer is keyed on that. One JSON file per designer in database/seeders/data/enrichment/, each a map of slug to entry. There are 72 files now. Here is the entry for my own boat, which ends up on the Northeaster Dory page (description trimmed):

"chesapeake-light-craft-northeaster-dory": {
  "description": "A 17 ft round-sided lapstrake dory for oars or sail...\n\nIt uses CLC's LapStitch method...",
  "loa_mm": 5182,
  "beam_mm": 1422,
  "construction_method": "lapstrake plywood",
  "plans_url": "https://clcboats.com/products/northeaster-dory",
  "source_url": "https://clcboats.com/products/northeaster-dory",
  "confidence": "high"
}
Enter fullscreen mode Exit fullscreen mode

JSON because it is pure data, it diffs cleanly, and any script can read and write it. One file per designer because that is how the research happened: one designer's site open in a tab, working down their list.

source_url and confidence are never stored. They are notes for whoever checks a number later, usually me.

Applied last

Every deploy runs the seeder after migrations, so the seeder is the source of truth for anything it owns. The order inside run() matters:

foreach ($rows as $row) {
    // update existing rows, insert new ones
}

$this->creditDesigners();

// Last, so nothing above can write the composed one-liner back over a
// researched page.
$this->applyEnrichment();
$this->stripBlockedPlansLinks();
Enter fullscreen mode Exit fullscreen mode

If enrichment ran first, the main loop would overwrite every researched description with the composed one-liner. Running it last gives the research the final say, and the vendor files stay exactly as crawled.

The merge rules

The rules are the interesting part, because "apply the JSON" is not one rule. A description always wins. A measurement only fills a blank:

$corrects = $e['corrects'] ?? [];

foreach (['loa_mm', 'beam_mm', 'construction_method',
          'hull_material', 'year_start', 'year_end'] as $field) {
    if (! empty($e[$field])
        && (empty($row->{$field}) || in_array($field, $corrects, true))) {
        $update[$field] = $e[$field];
    }
}
Enter fullscreen mode Exit fullscreen mode

A figure verified by hand outranks one read off a page in bulk, and a research file should not quietly replace it.

But the research also found the catalog was wrong in places. A lot of the original lengths were nominal, read off the name. An "Archimedes 22" was stored as 22 ft 0 in when Devlin's own page says 22 ft 10 in. So an entry can list a field under corrects, which lets it overwrite. That gets used only where the designer's page plainly disagrees, about 200 times across all the files.

plans_url always replaces, because the whole point is to swap a designer's homepage for the specific design's page. designer_name fills a blank, or replaces a byline that just repeats the house name, or corrects one outright when listed in corrects. That last case exists because CLC's Shearwater 17 is Eric Schade's design and Bateau resells Richard Woods's catamarans under its own name.

Idempotency, missing slugs and duplicates

Running the seeder twice gives the same result. Every write is an update to a value the files already define, and the fill-a-blank rule means a second run finds nothing blank to fill.

A slug with no row is skipped, not inserted:

$row = DB::table('boat_models')->where('slug', $slug)->first();

if (! $row) {
    continue;
}
Enter fullscreen mode Exit fullscreen mode

Enrichment describes the catalog; it does not add to it. Some entries target rows that exist only in production, through a builder's proposal, so on a fresh test database they quietly do nothing.

Quietly doing nothing is also the danger. If I renamed a design in a vendor file, its slug would change and its enrichment entry would silently stop applying. The guard is a test that seeds a fresh database and counts how many entries actually landed:

$this->assertGreaterThan(400, $applied,
    'Enrichment slugs do not match the seeded catalog.');
Enter fullscreen mode Exit fullscreen mode

It is a blunt threshold, not a per-slug check, and I know it would miss a handful going stale. What it does catch is the case I worried about most, a whole file drifting.

Duplicates were a separate problem. The research turned up three designs that were in the catalog twice under different names, and ten rows that were never home-build designs at all (factory-built kayaks, a passenger ferry, a couple of cradles). The seeder only inserts and updates, and most of those rows were not in a seed file, so a migration removed them. Each old URL 301s from a config map, a duplicate to its survivor and the rest to the designer's shelf, because those pages were already in the sitemap.

One gap I have not closed: nothing checks for a repeated slug. A quick script finds no slug in two files today, but a repeat inside one file would never show up, because json_decode keeps the last value without complaint.

Validation

The files are data and will be re-researched, so the tests pin the contract of the files rather than any one boat. CatalogEnrichmentTest checks that:

  • every description is at least two paragraphs and 45 words, with no em dashes, en dashes or carriage returns
  • construction_method is one of the catalog's own words ("stitch and glue", "lapstrake plywood", "strip planked" and so on), so filters keep working
  • lengths and beams are integers between 300 mm and 40 m, and the beam is less than the length
  • a tampered length survives a second seed, which proves enrichment is not overwriting non-blank values
  • an enriched design page renders with at least two paragraphs and no noindex

The beam-wider-than-length check sounds silly, but with close to 2,000 entries typed by hand, a swapped pair of fields is exactly the kind of mistake I expect to make.

Nothing here needs more than what the Laravel seeding docs cover: DB::table() and a test calling $this->seed().

What went wrong along the way

The seeder redated every design on every deploy. The original loop used updateOrInsert with created_at = now(). Since it runs on every deploy, every design claimed to be new that morning. I only caught it when I started building a fortnightly digest of new designs, because "what is new" had no answer. Update and insert are now separate, and the dates belong only to the insert.

A migration fix got undone by the seeder. I standardized on American spelling with a migration. Three descriptions came back British in the same deploy, because the seeder ran after the migration and restored them from the seed data. Lesson: anything the seeder owns has to be fixed at the source, or it comes back.

A dead plans link. One designer's old site now serves something unrelated, and the "Where to get plans" button pointed at it. Clearing the column by hand would only last until someone proposed another design with the same link. So the seeder strips a short list of blocked hosts after enrichment, and the JSON entry keeps a plans_url_removed note explaining why, so I do not put it back.

How it shows on the page

The design page splits the description on blank lines and renders each as a paragraph. The meta description leads with the first sentence of the write-up. Splitting on ". " cut "designed by John C. Harris" after the initial, so the split now refuses to break after a lone capital:

preg_split('/(?<![A-Z])\.\s+(?=[A-Z0-9])/u', trim($model->description), 2)[0]
Enter fullscreen mode Exit fullscreen mode

Below the write-up sits the section the whole site exists for: "What building a Northeaster Dory actually takes", medians from logged builds, not the designer's estimate. The researched specs fill the spec panel, the plans button goes to the design's own page, and a design with a length, a construction method and a designer counts as having specs.

Once every design had a real description, I dropped the thin-content gate and put the whole catalog in the sitemap.

Would I do it again?

Yes. The vendor files stay as crawled, the research sits in its own reviewable layer, and the seeder applies one over the other in a fixed order on every deploy. The weak spots are the blunt slug-drift test and the missing repeated-slug check, and both are on my list.

If you are building a boat and want your hours to count toward those medians, the logs are at boatbuildlog.com.

Top comments (0)