A one-off script is often treated as less important than regular application code.
It may never be maintained for years. It may exist only for one import, content migration, or data repair.
But if that script changes a production database, its failures are fully production failures: duplicates, wrong relationships, broken translations, leaked internal data, or content already visible to users.
A one-off importer should be judged not by how long it lives, but by what data it can change.
First Guard: Do Not Create the Same Content Twice
if ( Post::withTrashed() ->where( 'slug', $slug, ) ->exists()) { throw new RuntimeException( 'Post already exists: '.$slug );}This guard looks almost too simple, but it closes a very real failure mode: the script is run twice because the result of the first run was overlooked.
Using withTrashed() also protects against recreating a softly deleted record that still owns the same URL identifier.
Validate the Category Before Creating the Record
$category = Category::query() ->where( 'slug', $categorySlug, ) ->where( 'status', true, ) ->first(); if ($category === null) { throw new RuntimeException( 'Active category was not found.' );}If an importer assumes that a category exists without checking it, the failure may appear far away from the actual mistake.
It is better to stop immediately than to create content with a missing or incorrect relationship.
It is often useful to validate not only existence, but also that the category is active.
Public Content Should Not Accidentally Contain Internal Identifiers
if ( preg_match( '/\b(?:ABC|OPS|APP)-\d+\b/', $payload, ) === 1) { throw new RuntimeException( 'Internal issue key found.' );}While preparing content, it is easy to leave behind an internal task reference, service name, or other detail that was useful during implementation but should not appear publicly.
A small payload check before writing is cheaper than manually searching for leaked details after publication.
Build Bilingual Content in Pairs
$add( $heading( 'Ukrainian Heading', 'same-anchor', ), $heading( 'English Heading', 'same-anchor', ),);When Ukrainian and English blocks are built as independent arrays, it is easy to add a block in only one language or change the order in one version.
Building them in pairs makes the structure explicit: one position always has both language variants of the same type.
if ( $uk['type'] !== $en['type']) { throw new RuntimeException( 'UK/EN block type mismatch.' );}If one locale has a heading at a position while the other has a paragraph, the two versions no longer represent the same structure.
It is better to stop the import immediately than to let the two page versions drift apart.
Heading Anchors Should Match Too
if ( $anchors['uk'] !== $anchors['en']) { throw new RuntimeException( 'UK/EN heading anchors mismatch.' );}Even when the headings are translated correctly, different anchors can break the table of contents, direct section links, or navigation.
This is another almost free check that protects the structure of the content.
The Importer Should Know Which Block Types It May Create
$allowed = [ 'heading', 'rich_text', 'code', 'notice', 'quote',]; foreach ($blocks as $block) { if ( ! in_array( $block['type'], $allowed, true, ) ) {This list acts as a small trust boundary.
If the prepared payload suddenly contains a block type that this importer was not designed to create, it is better to stop and investigate than to write it silently.
Syntax Validation Before Execution Is the Cheapest Insurance
php -l import-script.phpphp -l does not validate business rules and cannot tell whether the correct record will be created.
But it removes the simplest class of failures before the application is even involved: syntax errors, missing brackets, or an accidentally damaged file.
For a script executed manually on a server, that is extremely cheap and useful protection.
Valid Syntax Does Not Mean a Safe Import
This boundary matters: syntax validation answers only "can PHP parse this file?"
It does not know whether the category exists, whether the content is duplicated, whether translations align, or whether internal data leaked into the payload.
Syntax validation and data safeguards complement each other rather than replace each other.
Wrap the Write in a Transaction
$postId = DB::transaction( function (): int { $post = Post::create([ // ... ]); syncTranslations($post); syncBlocks($post); return $post->id; });If the main record is created but translation or block synchronization fails, a script without a transaction may leave half-finished data behind.
A transaction does not fix wrong logic, but it reduces partially completed imports.
Verify What Was Actually Stored
Another useful habit is to end the script not with a generic "done", but with verification of the created record.
For example, read the content back from the database, count blocks for both locales, compare their types, and only then return an explicit success result.
This turns the script from "try to write" into a small "write and verify" procedure.
result: OKstatus: draftcategory: devopsuk_blocks: 24en_blocks: 24same_types: trueguards: duplicate: passed category: passed internal_keys: passed anchors: passedA Good Importer Should Stop Rather Than Silently Write Suspicious Data
For a one-off import, stopping is usually cheaper than writing incorrect data.
If the category is missing, a block type is unknown, or locale structures drift apart, it is better to fail and fix the script.
Silently trying to continue often moves the problem from code into production data.
My Minimum Checklist for a One-Off Importer
[ ] Lint before execution[ ] Guard against duplicates[ ] Validate external relationships[ ] Allow only expected block types[ ] Pair UK and EN blocks[ ] Verify matching anchors[ ] Reject internal identifiers[ ] Write inside a transaction[ ] Read back and verify stored data[ ] Return an explicit success resultOne-Off Does Not Mean Low Risk
A one-off script may live for five minutes and never run again.
But the data it creates may remain in the system for years.
That is why small guards before writing have such high value: they are cheap, local, and stop mistakes before those mistakes become production-data problems.
If a script has permission to change production data, it deserves to be treated as production code.
A script does not stop being production code just because it is intended to run once.



