Implementation of Product Import Preview Before Application
Import preview is the ability to see exactly what will change in the catalog before clicking "Apply". It's especially important with manual uploads and when working with new suppliers: an error in column mapping can rewrite prices with incorrect values for thousands of products.
Dry-Run Mode Architecture
The entire import system must support the $dryRun flag:
class ProductImportService
{
public function import(iterable $rows, ImportConfig $config, bool $dryRun = false): ImportPreview|ImportResult
{
$preview = new ImportPreview();
foreach ($rows as $line => $row) {
$sanitized = $this->sanitizer->sanitize($row);
$validated = $this->validator->validate($sanitized);
if (!$validated->valid) {
$preview->addError($line, $row['sku'] ?? '?', $validated->errors);
continue;
}
$diff = $this->computeDiff($validated->data, $config->sourceId);
$preview->addItem($line, $diff);
}
if ($dryRun) {
return $preview; // return only analysis, write nothing to DB
}
return $this->applyPreview($preview, $config);
}
}
Computing Diff for Each Product
class ImportDiffComputer
{
public function compute(array $newData, int $sourceId): ItemDiff
{
$existing = Product::where('sku', $newData['sku'])
->where('source_id', $sourceId)
->first();
if (!$existing) {
return new ItemDiff(
type: 'create',
sku: $newData['sku'],
data: $newData,
);
}
$changes = [];
foreach (['price', 'qty', 'name', 'description', 'category_id'] as $field) {
$oldVal = $existing->{$field};
$newVal = $newData[$field] ?? null;
if ((string) $oldVal !== (string) $newVal) {
$changes[$field] = ['old' => $oldVal, 'new' => $newVal];
}
}
if (empty($changes)) {
return new ItemDiff(type: 'unchanged', sku: $newData['sku']);
}
return new ItemDiff(
type: 'update',
sku: $newData['sku'],
changes: $changes,
);
}
}
Storing Preview in Temporary Storage
Preview cannot be stored in memory — files are large. Use temporary table or Redis:
Option with Temporary Database Table
CREATE TABLE import_previews (
id serial PRIMARY KEY,
session_token varchar(64) UNIQUE,
source_id int,
user_id int,
total_rows int,
create_count int,
update_count int,
unchanged_count int,
error_count int,
expires_at timestamptz DEFAULT now() + INTERVAL '2 hours',
created_at timestamptz DEFAULT now()
);
CREATE TABLE import_preview_items (
id bigserial PRIMARY KEY,
preview_id int REFERENCES import_previews(id) ON DELETE CASCADE,
line_number int,
sku varchar(100),
operation varchar(10), -- create | update | unchanged | error
changes jsonb,
errors jsonb
);
CREATE INDEX ipi_preview_op_idx ON import_preview_items (preview_id, operation);
Saving Preview
class ImportPreviewRepository
{
public function store(ImportPreview $preview, int $sourceId, int $userId): string
{
$token = bin2hex(random_bytes(32));
$record = ImportPreviewRecord::create([
'session_token' => $token,
'source_id' => $sourceId,
'user_id' => $userId,
'total_rows' => $preview->totalCount(),
'create_count' => $preview->countByType('create'),
'update_count' => $preview->countByType('update'),
'unchanged_count' => $preview->countByType('unchanged'),
'error_count' => $preview->countByType('error'),
]);
// Insert items in batches
foreach (array_chunk($preview->items(), 1000) as $batch) {
ImportPreviewItem::insert(array_map(
fn($item) => [
'preview_id' => $record->id,
'line_number' => $item->line,
'sku' => $item->sku,
'operation' => $item->type,
'changes' => $item->changes ? json_encode($item->changes) : null,
'errors' => $item->errors ? json_encode($item->errors) : null,
],
$batch
));
}
return $token;
}
}
API for Display Preview in UI
class ImportPreviewController
{
// Preview summary
public function summary(string $token): JsonResponse
{
$preview = ImportPreviewRecord::where('session_token', $token)
->where('expires_at', '>', now())
->firstOrFail();
return response()->json([
'token' => $token,
'summary' => [
'create' => $preview->create_count,
'update' => $preview->update_count,
'unchanged' => $preview->unchanged_count,
'errors' => $preview->error_count,
'total' => $preview->total_rows,
],
'expires_at' => $preview->expires_at,
]);
}
// Details with pagination and filtering
public function items(string $token, Request $request): JsonResponse
{
$preview = ImportPreviewRecord::where('session_token', $token)->firstOrFail();
$items = ImportPreviewItem::where('preview_id', $preview->id)
->when($request->operation, fn($q, $op) => $q->where('operation', $op))
->when($request->search, fn($q, $s) => $q->where('sku', 'like', "%{$s}%"))
->orderBy('line_number')
->paginate(50);
return response()->json($items);
}
// Apply preview
public function apply(string $token): JsonResponse
{
$preview = ImportPreviewRecord::where('session_token', $token)
->where('expires_at', '>', now())
->firstOrFail();
ApplyImportPreviewJob::dispatch($preview->id, auth()->id());
return response()->json(['status' => 'queued', 'import_id' => null]);
}
}
Displaying Changes in UI
For the changes field, JSON format diff is convenient for rendering:
{
"price": {"old": 4990, "new": 5490},
"qty": {"old": 15, "new": 0},
"name": {"old": "Item A", "new": "Item A Pro"}
}
In React interface:
const ChangeCell = ({ field, change }: { field: string; change: {old: any; new: any} }) => (
<span>
<del className="text-red-500">{change.old}</del>
{' → '}
<ins className="text-green-600">{change.new}</ins>
</span>
);
Partial Application of Preview
Operator can uncheck certain rows before applying:
public function applyPartial(string $token, array $excludeSkus): void
{
$preview = ImportPreviewRecord::where('session_token', $token)->firstOrFail();
ImportPreviewItem::where('preview_id', $preview->id)
->whereIn('sku', $excludeSkus)
->update(['operation' => 'excluded']);
}
Cleanup of Expired Previews
$schedule->command('import:cleanup-previews')->hourly();
ImportPreviewRecord::where('expires_at', '<', now())->each(function ($record) {
$record->delete(); // CASCADE will delete items
});
Timeline
- Dry-run mode, diff-computer, storage in temp-table — 2 days
- API summary/items/apply, UI with filtering by operation type — +1 day
- Partial application, expiration, cleanup — +0.5 day







