Search by

usman-ahmed / crud-engine

USmanFathy

Generic CRUD services, file lifecycle management, nested relation synchronization, a statistics engine, and Laravel macros — extracted into a reusable, testable, framework-friendly package.

Package info

github.com/USmanFathy/CRUD---Engine

pkg:composer/usman-ahmed/crud-engine

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-10-26 16:12 UTC

This package is auto-updated.

Last update: 2026-10-26 16:17:35 UTC


README

Generic CRUD services, file lifecycle management, nested relation synchronization, a statistics engine, and a handful of Laravel macros — extracted from an internal framework into a standalone, testable, dependency-injected Laravel package.

Supports Laravel 10, 11, and 12, on PHP 8.2+.

Installation

composer require usman-ahmed/crud-engine

The service provider and facade are auto-discovered — no manual registration needed.

Publish the config and translation files (optional):

php artisan crud-engine:install

This is equivalent to running both of:

php artisan vendor:publish --tag=crud-engine-config
php artisan vendor:publish --tag=crud-engine-lang

Core Concepts

The package never assumes anything about your models' structure. Instead, your Eloquent models opt in to behavior by implementing small capability interfaces, and every generic service asks a single CapabilityRegistry what a model supports — there's no scattered instanceof checking anywhere in your application code.

Interface Enables
UsmanAhmed\CrudEngine\Contracts\Capabilities\FileUpload Automatic file store/delete/URL-rewrite for declared attributes
UsmanAhmed\CrudEngine\Contracts\Capabilities\HasManyRelations Automatic diff-based sync of declared hasMany relations
UsmanAhmed\CrudEngine\Contracts\Capabilities\HasOneRelations Automatic update-or-create of declared hasOne relations
UsmanAhmed\CrudEngine\Contracts\Capabilities\ManyToManyRelations Automatic sync() of declared belongsToMany relations
UsmanAhmed\CrudEngine\Contracts\Capabilities\OriginalName Marker — keep the client's original filename instead of a hashed one

Quick Start

1. Implement capability interfaces on your model

use Illuminate\Database\Eloquent\Model;
use UsmanAhmed\CrudEngine\Contracts\Capabilities\FileUpload;
use UsmanAhmed\CrudEngine\Contracts\Capabilities\HasManyRelations;
use UsmanAhmed\CrudEngine\Traits\HasFileUrlsTrait;

class Product extends Model implements FileUpload, HasManyRelations
{
    use HasFileUrlsTrait; // rewrites file attributes into full URLs in toArray()

    public function documentFullPathStore(): string
    {
        // Any structure you want — the package never assumes one.
        return 'products/'.$this->getKey();
    }

    public function requestKeysForFile(): array
    {
        return ['cover_image'];
    }

    public function getHasManyRelations(): array
    {
        return ['variants'];
    }

    public function variants()
    {
        return $this->hasMany(ProductVariant::class);
    }
}

2. Extend the abstract Crud service for your resource

use UsmanAhmed\CrudEngine\Services\Crud\AbstractStoreService;

class ProductStoreService extends AbstractStoreService
{
    public function model(): string
    {
        return Product::class;
    }

    public function requestFile(): string
    {
        return ProductStoreRequest::class; // any class with a public rules(): array method
    }
}

3. Use it in a controller

class ProductController extends Controller
{
    public function store(ProductStoreService $service)
    {
        $result = $service->store();

        return response()->success($result->data, $result->messages, $result->code);
    }
}

That's it — validation (only declared fields are persisted), file storage, and relation syncing for variants all happen automatically, in that order, with the file write deferred until after the database transaction commits.

Updating and Deleting

use UsmanAhmed\CrudEngine\Services\Crud\AbstractUpdateService;
use Illuminate\Database\Eloquent\Model;

class ProductUpdateService extends AbstractUpdateService
{
    public function __construct(private Product $product, /* ...parent deps via DI... */) { /* ... */ }

    public function model(): string { return Product::class; }
    public function requestFile(): string { return ProductUpdateRequest::class; }
    public function resolveModel(): Model { return $this->product; }
}
use UsmanAhmed\CrudEngine\Services\Crud\AbstractBulkDeleteService;

class ProductBulkDeleteService extends AbstractBulkDeleteService
{
    public function model(): string
    {
        return Product::class;
    }
}

AbstractBulkDeleteService reads ids from the current request by default (override resolveIds() to change that), and safely handles a scalar ids value instead of throwing.

Statistics

use UsmanAhmed\CrudEngine\Services\Statistics\AbstractStatisticsService;

class ProductStatisticsService extends AbstractStatisticsService
{
    public function getModelClass(): string { return Product::class; }
    public function getDateColumn(): string { return 'created_at'; }
}

// In a controller:
$service->getStatistics('2026-01-01', '2026-01-31', 'days');
// => ['2026-01-01' => 3, '2026-01-02' => 0, ..., '2026-01-31' => 1]

Results are cached (crud-engine.statistics.cache_ttl, default 300 seconds). The query engine defaults to pure Eloquent (portable across MySQL/Postgres/SQLite); set crud-engine.statistics.query_strategy to spatie if you've separately installed spatie/laravel-query-builder and want its filtering conventions instead.

Macros

Available globally once the package is installed, no setup required:

// Migrations
Schema::create('products', function (Blueprint $table) {
    $table->id();
    $table->status();       // tinyInteger('status')->default(1)
    $table->standardTime(); // created_at, updated_at, soft deletes
});

// Queries
Product::query()->customOrdering('name', 'asc'); // or relation: 'category.name'
Product::query()->datesFiltering();              // reads period_type/from_date/to_date from the request

// Carbon
Carbon::parseOrNow($maybeInvalidDate); // never throws

// Str
Str::snakeToTitle('product_name'); // "Product Name"
Str::humanText('product---name!!'); // "Product Name"

// Responses
return response()->success($data, ['crud-engine::responses.success.created']);
return response()->error('Something specific went wrong.');

Events

Subscribe to any of these instead of, or in addition to, the package's default logging listener:

  • RecordCreated, RecordUpdated, RecordDeleted, RecordDeletionFailed
  • FileStored, FileDeleted
  • RelationSynced
Event::listen(RecordCreated::class, function (RecordCreated $event) {
    Cache::forget("product:{$event->model->getKey()}");
});

Disable the package's own logging listener with crud-engine.log_operations = false if you only want your own listeners.

Overriding Defaults

Every behavior described above is bound against an interface and can be swapped from your own AppServiceProvider:

$this->app->bind(
    \UsmanAhmed\CrudEngine\Contracts\Files\FilePathResolverInterface::class,
    \App\Services\TenantAwarePathResolver::class,
);

$this->app->bind(
    \UsmanAhmed\CrudEngine\Contracts\Responses\ResponseFormatterInterface::class,
    \App\Services\JsonApiResponseFormatter::class,
);

Configuration

After publishing, see config/crud-engine.php for: strict_validation, strict_capabilities, files.disk, relations.max_recursion_depth, statistics.query_strategy, statistics.cache_ttl, response_formatter, and log_operations.

Testing

composer install
vendor/bin/phpunit

The test suite uses Orchestra Testbench against an in-memory SQLite database. It includes dedicated regression tests for every bug fixed during the refactor (see CHANGELOG.md) — most notably the HasOne/HasMany recursion mix-up, the validation mass-assignment gap, the orphaned-file bug, and the original-filename path-traversal fix.

License

Copyright © 2026 Usman Ahmed. All rights reserved.

This package is proprietary software. Use, copying, modification, and redistribution are permitted only under a valid written license issued by Usman Ahmed. See LICENSE for the full terms. Third-party dependencies remain subject to their own licenses.