Purser v1.0

یک pipeline توزیع‌شده و event-driven برای ایمپورت اکسل در Laravel. driverهای قابل تعویض، اعتبارسنجی سطر به سطر، بازیابی خطا در سطح سطر، و handlerی که خودتان می‌نویسید.

PHP 8.1+ Laravel 10 · 11 · 12 MIT Event-Driven Queue-Based
GitHub Packagist

چرا این پکیج؟

بیشتر پکیج‌های اکسل، ایمپورت را یک عملیات واحد می‌بینند: فایل را بخوان، سطرها را تحویل بده، تمام. این کار تا زمانی جواب می‌دهد که یک سطر وسط یک فایل ۵۰٬۰۰۰ سطری رد نشود، یا یک chunk job نمیرد و ندانید کدام سطرها commit شده‌اند، یا یک بازبین، audit trail هر سطر ردشده را از شما نخواهد.

Purser ایمپورت را یک pipeline چندمرحله‌ای می‌بیند. هر مرحله یک job صف‌محور مستقل و قابل retry است. هر خطای اعتبارسنجی در برابر سطری که باعث آن شده ثبت می‌شود. pipeline با یا بدون سطرهای معتبر به پایان می‌رسد.

پیش‌نیازها

  • PHP نسخهٔ 8.1 یا بالاتر
  • Laravel نسخه‌های 10، 11 یا 12
  • یکی از دو بستهٔ خواننده:
    • maatwebsite/excel — driver پیش‌فرض، مبتنی بر PhpSpreadsheet
    • openspout/openspout — streaming driver، مصرف حافظهٔ ثابت
  • یک queue connection دیتابیسی برای production

نصب

composer require akbarjimi/purser
php artisan vendor:publish --tag=purser
php artisan vendor:publish --tag=purser-sheets
php artisan migrate

دستور publish اول، فایل config/purser.php را نصب می‌کند. دستور دوم، config/purser-sheets.php را نصب می‌کند؛ جایی که ستون‌های spreadsheet را به فیلدهای دامنهٔ خود map می‌کنید و قواعد اعتبارسنجی هر sheet را تعریف می‌کنید.

پنج جدول ساخته می‌شود: excel_files، excel_sheets، excel_rows، excel_row_chunks و excel_row_errors.

شروع سریع

۱. یک handler بنویسید

handler یک stream از سطرهای معتبر دریافت می‌کند و هر کاری که اپلیکیشن شما نیاز دارد انجام می‌دهد.

<?php

declare(strict_types=1);

namespace App\Imports;

use Akbarjimi\Purser\Contracts\ImportHandler;
use Akbarjimi\Purser\DTOs\ValidatedRow;
use App\Models\User;

final class UserImportHandler implements ImportHandler
{
    public function handle(int $fileId, iterable $rows): void
    {
        foreach ($rows as $row) {
            assert($row instanceof ValidatedRow);

            User::updateOrCreate(
                ['email' => $row->data['email']],
                [
                    'name' => $row->data['name'],
                    'age'  => $row->data['age'],
                ],
            );
        }
    }
}

۲. sheet را در config/purser-sheets.php پیکربندی کنید

return [
    'Users' => [
        'mapping' => [
            'name'  => 'A',
            'email' => 'B',
            'age'   => 'C',
        ],
        'validation' => [
            'name'  => 'required|string|max:255',
            'email' => 'required|email',
            'age'   => 'required|integer|min:18',
        ],
    ],
];

۳. ایمپورت را dispatch کنید

use Akbarjimi\Purser\Services\ImportManager;
use App\Imports\UserImportHandler;

app(ImportManager::class)
    ->import('uploads/users.xlsx', disk: 's3')
    ->withHandler(UserImportHandler::class)
    ->dispatch();

pipeline به‌صورت asynchronous اجرا می‌شود. دستور excel:status {fileId} پیشرفت را نشان می‌دهد، excel:retry {fileId} chunkهای failed را دوباره dispatch می‌کند، و سطرهای ردشده از طریق ErrorReportService در دسترس‌اند.

نکته: handler فقط پس از پردازش همهٔ chunkها فراخوانی می‌شود. سطرهایی که اعتبارسنجی را رد نکرده‌اند، در excel_row_errors ذخیره می‌شوند و هرگز به handler نمی‌رسند.

معماری pipeline

هر ایمپورت از شش مرحله عبور می‌کند. هر مرحله یک job صف‌محور یا event listener است. هر مرحله خروجی‌اش را پیش از signal دادن به مرحلهٔ بعد persist می‌کند. هیچ چیز بین مراحل در حافظه نگه داشته نمی‌شود.

۱
Registration

ساخت رکورد ExcelFile و fire رویداد ExcelFileRegistered.

۲
Sheet Discovery

کشف sheetها با SheetDiscoveryService و persist آن‌ها در excel_sheets.

۳
Row Extraction

استخراج سطرها با batch‌ای از ExtractSheetRowsJob، یکی برای هر sheet.

۴
Chunking

ساخت ExcelRowChunk با اندازهٔ chunk_size برای هر sheet.

۵
Processing

اجرای ProcessChunkJobها با allowFailures(true)؛ هر chunk مستقل پردازش می‌شود.

۶
Handler Invocation

handler شما با یک LazyCollection از ValidatedRowها فراخوانی می‌شود.

هر مرحله می‌تواند شکست بخورد و بدون اجرای دوبارهٔ مراحل قبلی retry شود. هر انتقال وضعیت توسط یک enum state machine اعتبارسنجی می‌شود. هیچ حالت مشترک قابل‌تغییری بین مراحل وجود ندارد.

رویدادها و state machine

رویدادهای عمومی

رویدادمعنا
ExcelFileRegisteredرکورد فایل ساخته شد، pipeline شروع شد.
FileSheetsScanCompletedsheetها کشف و persist شدند.
AllRowsExtractedهمهٔ سطرهای هر sheet در excel_rows هستند.
FileProcessingCompletedهمهٔ chunkها پردازش شدند و handler فراخوانی شد.

هر چهار رویداد پس از commit دیتابیس dispatch می‌شوند.

State machine فایل

PENDING      -> READING | FAILED
READING      -> ROWS_EXTRACTING | ROWS_EXTRACTED | FAILED
ROWS_EXTRACTED -> PROCESSING | FAILED
PROCESSING   -> COMPLETED | FAILED
FAILED       -> PROCESSING    (retry)
COMPLETED    -> (terminal)

هر موجودیت (file، sheet، row، chunk) enum مخصوص خود را با canTransitionTo() دارد. انتقال‌های غیرمجاز از طریق HasStatusTransitions یک RuntimeException پرتاب می‌کنند. انتقال‌های idempotent (از یک state به خودش) no-op هستند.

Idempotency در هر مرحله

  • HandleExcelFileRegistered اگر sheetها موجود باشند، discovery را skip می‌کند.
  • SheetRowBuffer با کلید (excel_sheet_id, content_hash, hash_algo) upsert می‌کند.
  • ProcessChunkJob اگر chunk قبلاً COMPLETED باشد بلافاصله return می‌کند.
  • ChunkerService در یک transaction با سه retry و کلید یکتای (excel_sheet_id, from_row_id, to_row_id) اجرا می‌شود.

Reader drivers

موتور خواندن پشت قرارداد ExcelReaderDriver قرار دارد. دو پیاده‌سازی همراه پکیج ارائه می‌شود که معامله‌های مهندسی متفاوتی دارند.

PhpSpreadsheetDriver

پیش‌فرض

از maatwebsite/excel استفاده می‌کند. کل فایل را در حافظه بارگذاری می‌کند. برای فایل‌های تا چند ده هزار سطر مناسب است.

حافظه
~۱KB per cell
فرمت‌ها
xls، xlsx، ods، csv
totalRows
دقیق

OpenSpoutDriver

Streaming

از openspout/openspout استفاده می‌کند. خوانندهٔ streaming با حافظهٔ ثابت بدون توجه به اندازهٔ فایل.

حافظه
ثابت
فرمت‌ها
xlsx، csv
totalRows
۰ (مستند شده)

انتخاب driver

# از طریق .env
EXCEL_IMPORTER_DRIVER=openspout

# یا در config/purser.php
'driver' => 'openspout',

اگر بستهٔ متناظر نصب نباشد، driver در نخستین استفاده MissingDriverDependencyException پرتاب می‌کند و دستور composer لازم را نام می‌برد.

واگرایی totalRows

PhpSpreadsheetDriver اعداد دقیق سطر و ستون برمی‌گرداند؛ OpenSpoutDriver همیشه 0 برمی‌گرداند چون بدون مصرف کل sheet نمی‌تواند پیش‌شمارش کند. اگر قبل از extraction شمارش دقیق نیاز دارید، از driver نخست استفاده کنید.

اعتبارسنجی و transformation

دو مرحلهٔ اختیاری بین reader و handler اجرا می‌شوند. ترتیب ثابت است:

raw row (column letters)
  -> mapping
  -> transformer
  -> validation
  -> ValidatedRow

Mapping

mapping یک آرایهٔ config است، نه یک کلاس. کلیدها نام فیلدهایی هستند که handler شما خواهند دید، و مقادیر حروف ستون‌ها هستند.

Validation

قواعد استاندارد Laravel که مستقیم به Validator::make() پاس داده می‌شوند. اعتبارسنجی روی خروجی transformer اجرا می‌شود، نه روی سطر خام.

اگر sheet هیچ قاعده‌ای نداشته باشد و strict_validation روی false باشد، همهٔ سطرها قبول می‌شوند. در production مقدار را true بگذارید تا misconfiguration را زودتر بگیرید.

Transformer

یک کلاس که TransformerInterface را پیاده‌سازی می‌کند. از طریق container resolve می‌شود، پس constructor injection کار می‌کند.

<?php

namespace App\Transformers;

use Akbarjimi\Purser\Contracts\TransformerInterface;
use Akbarjimi\Purser\Models\ExcelSheet;

final class UserTransformer implements TransformerInterface
{
    public function transform(array $mappedRow, ExcelSheet $sheet): array
    {
        return [
            'name'  => trim((string) $mappedRow['name']),
            'email' => strtolower(trim((string) $mappedRow['email'])),
            'age'   => (int) $mappedRow['age'],
        ];
    }
}

اگر transformer exception پرتاب کند، سطر با وضعیت FAILED و error_type: system ثبت می‌شود و chunk به کار خود ادامه می‌دهد.

بازیابی خطا

pipeline طوری طراحی شده که یک سطر خراب نتواند ایمپورت را متوقف کند. این ارزش اصلی پکیج است.

دو کلاس خطا

نوعدامنهرفتار
Validation failure per row، مورد انتظار سطر FAILED_VALIDATION می‌شود؛ یک ExcelRowError برای هر نقض قاعده نوشته می‌شود؛ chunk ادامه می‌یابد.
System failure per chunk، غیرمنتظره chunk با پیام exception در ستون error علامت‌گذاری می‌شود؛ job دوباره throw می‌کند تا Laravel retry کند.

خواندن خطاها

use Akbarjimi\Purser\Services\ErrorReportService;

$service = app(ErrorReportService::class);

$page = $service->paginate($fileId, perPage: 50);
$json = $service->toJson($fileId);
$path = $service->toSpreadsheet($fileId, disk: 'local');

workflow بازیابی

دستور excel:retry فقط روی فایل‌هایی با وضعیت FAILED کار می‌کند. سه پیش‌شرط دارد:

  1. فایل موجود و soft-delete نشده باشد.
  2. وضعیت فایل FAILED باشد.
  3. حداقل یک chunk وضعیت FAILED داشته باشد.

در این صورت، فایل به PROCESSING می‌رود، chunkهای failed به PENDING بازنشانی می‌شوند، و یک Bus::batch جدید dispatch می‌شود. chunkهای completed دست‌نخورده باقی می‌مانند.

مهم: اگر شکست ناشی از داده‌ای باشد که تغییر نخواهد کرد، retry دوباره شکست می‌خورد. ستون error روی excel_row_chunks علت را نام می‌برد.

پیکربندی

دو فایل به config/ منتشر می‌شوند: purser.php برای تنظیمات سراسری pipeline و purser-sheets.php برای mapping و validation هر sheet.

کلیدهای اصلی در purser.php

کلیدپیش‌فرضکاربرد
drivermaatwebsitemaatwebsite یا openspout
chunk_size1000تعداد سطر در هر chunk
insert_batch_size100تعداد سطر در هر batch دیتابیسی
hash_algosha256الگوریتم hash برای dedup سطرها
max_sheets50رد فایل‌هایی که از این تعداد sheet بیشتر دارند
strict_validationfalsethrow در صورت نبود قاعده برای یک sheet
default_disklocaldisk پیش‌فرض برای فایل‌های آپلودشده
queuedefaultنام queue connection برای jobها

متغیرهای محیطی

EXCEL_IMPORTER_DRIVER=openspout
EXCEL_IMPORTER_CHUNK_SIZE=500
EXCEL_IMPORTER_INSERT_BATCH_SIZE=200
EXCEL_IMPORTER_DISK=s3
EXCEL_IMPORTER_QUEUE=imports
EXCEL_IMPORTER_HASH_ALGO=sha256
EXCEL_IMPORTER_STRICT_VALIDATION=true
EXCEL_IMPORTER_LOG_ENABLED=true

دستورات کنسول

سه دستور Artisan همراه پکیج ارائه می‌شود.

excel:status {fileId}

وضعیت کامل یک ایمپورت را چاپ می‌کند: متادیتای فایل، وضعیت هر sheet، شمارش chunkها بر اساس وضعیت، شمارش سطرها بر اساس وضعیت، و تعداد کل خطاها.

File #42
+----------------+---------------------+
| Field          | Value               |
+----------------+---------------------+
| Name           | users.xlsx          |
| Status         | completed           |
| Rows Extracted | 2026-09-21 10:14:05 |
| Completed      | 2026-09-21 10:14:19 |
+----------------+---------------------+

Errors: 152

excel:retry {fileId}

chunkهای failed را دوباره dispatch می‌کند. پیش‌شرط‌ها در بخش بازیابی خطا توضیح داده شده‌اند.

excel:benchmark

یک fixture مصنوعی .xlsx می‌سازد، آن را از کل pipeline عبور می‌دهد، و زمان هر فاز و مصرف حافظه را چاپ می‌کند. برای مقایسهٔ driverها یا تنظیم chunk_size استفاده می‌شود.

php artisan excel:benchmark --rows=50000 --driver=openspout

تست و مشارکت

اجرای تست‌ها

composer test
# یا مستقیم:
vendor/bin/pest

# با coverage:
vendor/bin/pest --coverage --min=80 --ci

Code style

Laravel Pint با preset laravel. پیش از هر commit اجرا کنید:

vendor/bin/pint

قالب commit

<type>(<scope>): <imperative, lowercase, ≤ 72 chars>

# نمونه‌ها:
fix(processor): persist row errors outside chunk transaction
feat(drivers): add csv reader driver
docs: clarify error report structure

امنیت

آسیب‌پذیری‌ها را به‌جای issue عمومی، به security@akbarjimi.com گزارش دهید. جزئیات کامل در SECURITY.md آمده است.

لایسنس

این پروژه تحت لایسنس MIT منتشر شده است. Copyright © 2026 Mohammad Akbari.

برای مطالعهٔ متن کامل لایسنس به فایل LICENSE.md در مخزن مراجعه کنید.

مشاهدهٔ LICENSE.md