khanhartisan/laravel-backbone 问题修复 & 功能扩展

解决BUG、新增功能、兼容多环境部署,快速响应你的开发需求

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

khanhartisan/laravel-backbone

Composer 安装命令:

composer require khanhartisan/laravel-backbone

包简介

Backbone for Laravel projects

README 文档

README

khanhartisan/laravel-backbone is a Laravel package that provides structured conventions and reusable building blocks for backend development. It helps you ship consistent JSON APIs, organize Eloquent side effects, handle soft-delete cascades at scale, and record high-volume counters — with minimal boilerplate.

Requirements

Dependency Version
PHP ^8.2
Laravel ^11.0, ^12.0, or ^13.0

Features

  • JsonController — Convention-based REST API controllers with hooks for validation, transactions, query scopes, and response metadata
  • Repository — A thin data-access layer used by JsonController, swappable per resource
  • Model Listeners — Priority-ordered, event-scoped listeners as an alternative to monolithic model observers
  • Relation Cascade — Application-layer, chunked cascade delete/restore for soft-deleted models
  • Counter — Redis-backed recording with batched database persistence for high-traffic metrics
  • JsonApiTest — One-liner CRUD feature tests for JSON API endpoints

Table of Contents

Installation

Install via Composer:

composer require khanhartisan/laravel-backbone

The package auto-registers KhanhArtisan\LaravelBackbone\BackboneServiceProvider. No manual registration is required.

Quick Start

1. Create a controller and API resource:

php artisan make:controller PostController
php artisan make:resource PostResource

2. Extend JsonController and wire up your model:

<?php

namespace App\Http\Controllers;

use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
use KhanhArtisan\LaravelBackbone\Http\Controllers\JsonController;

class PostController extends JsonController
{
    protected function modelClass(): string
    {
        return Post::class;
    }

    protected function resourceClass(): string
    {
        return PostResource::class;
    }

    public function index(Request $request): ResourceCollection
    {
        return $this->jsonIndex($request);
    }

    public function show(Request $request, Post $post): PostResource
    {
        return $this->jsonShow($request, $post);
    }

    public function store(StorePostRequest $request): PostResource
    {
        return $this->jsonStore($request);
    }

    public function update(UpdatePostRequest $request, Post $post): PostResource
    {
        return $this->jsonUpdate($request, $post);
    }

    public function destroy(Request $request, Post $post): PostResource
    {
        return $this->jsonDestroy($request, $post);
    }
}

3. Register routes:

Route::resource('posts', PostController::class);

That is the full wiring for a paginated index, show, create, update, and delete API backed by Laravel API Resources.

JsonController

JsonController is the core abstraction for building JSON REST APIs. Each public controller method delegates to a json* helper; customization happens through protected hook methods rather than overriding core logic.

Setup

Your controller must:

  1. Extend KhanhArtisan\LaravelBackbone\Http\Controllers\JsonController
  2. Implement modelClass() returning the Eloquent model FQCN
  3. Optionally override resourceClass() (defaults to JsonResource::class)
  4. Optionally override resourceCollectionClass() for a custom collection resource
  5. Optionally override repository() to use a custom repository

Ensure your model defines $fillable — only fillable attributes are persisted on store and update.

Routes

Use Laravel's standard resource routing:

use App\Http\Controllers\PostController;
use App\Http\Controllers\CommentController;

Route::resource('posts', PostController::class);
Route::resource('posts.comments', CommentController::class);
Route::resource('posts.comments', CommentController::class)->shallow();

CRUD Endpoints

Index — GET /posts

public function index(Request $request): ResourceCollection
{
    return $this->jsonIndex($request);
}

Uses SimplePaginationExecutor by default (Laravel's paginate()). Returns a paginated ResourceCollection.

Show — GET /posts/{post}

public function show(Request $request, Post $post): PostResource
{
    return $this->jsonShow($request, $post);
}

Applies show visitors, then wraps the model in your API resource.

Store — POST /posts

Create a Form Request with validation rules, then:

public function store(StorePostRequest $request): PostResource
{
    return $this->jsonStore($request);
}

Pass modified data as the second argument when needed:

$data = $request->validated();
$data['user_id'] = $request->user()->id;

return $this->jsonStore($request, $data);

Returns a JsonResource wrapping the created model. Set an explicit status code in your controller if you need 201 Created (the bundled JsonApiTest helper expects 201 by default).

Update — PATCH /posts/{post}

public function update(UpdatePostRequest $request, Post $post): PostResource
{
    return $this->jsonUpdate($request, $post);
}

Optionally pass modified validated data as the third argument:

return $this->jsonUpdate($request, $post, $request->validated());

Destroy — DELETE /posts/{post}

public function destroy(Request $request, Post $post): PostResource
{
    return $this->jsonDestroy($request, $post);
}

Returns the deleted resource with a 200 status code.

Extension Hooks

Override these protected methods to customize behavior without touching the core json* methods.

Hook Used by Default Purpose
storeWithTransaction() Store true Wrap create in a DB transaction
updateWithTransaction() Update true Wrap update in a DB transaction
destroyWithTransaction() Destroy true Wrap delete in a DB transaction
showResourceVisitors() Show [] Transform model before show response
storeResourceSavingVisitors() Store [] Transform model before save() on create
storeResourceSavedVisitors() Store show visitors Transform model after save() on create
updateResourceSavingVisitors() Update [] Transform model before save() on update
updateResourceSavedVisitors() Update show visitors Transform model after save() on update
destroyResourceDeletingVisitors() Destroy [] Transform model before delete()
destroyResourceDeletedVisitors() Destroy show visitors Transform model after delete()
showAdditional() Show [] Extra top-level JSON keys on show
storeAdditional() Store showAdditional() Extra keys on store response
updateAdditional() Update showAdditional() Extra keys on update response
destroyAdditional() Destroy showAdditional() Extra keys on destroy response
indexQueryScopes() Index [] Filter/sort the index query
indexCollectionVisitors() Index [] Transform the result collection
indexGetQueryExecutor() Index SimplePaginationExecutor Control how results are fetched
indexAdditional() Index $getData->additional() Extra keys on index response
repository() All Default Repository Swap the data-access layer

Note: Store, update, and destroy responses inherit showAdditional() and showResourceVisitors() by default unless you override the action-specific hooks.

Resource Visitors

Visitors let you mutate a model (or collection) at defined lifecycle points. They can be dedicated classes or inline closures.

Single model visitor — implement ResourceVisitorInterface:

<?php

namespace App\Models\Visitors;

use App\Models\Post;
use Illuminate\Database\Eloquent\Model;
use KhanhArtisan\LaravelBackbone\Eloquent\ResourceVisitorInterface;

class PostVisitor implements ResourceVisitorInterface
{
    public function apply(Model $model): void
    {
        /** @var Post $post */
        $post = $model;
        $post->title = strtoupper($post->title);
    }
}

Register it in the controller:

protected function showResourceVisitors(Request $request): array
{
    return [
        new PostVisitor(),
        fn (Post $post) => $post->loadCount('comments'),
    ];
}

Collection visitor — implement CollectionVisitorInterface:

<?php

namespace App\Models\Visitors;

use Illuminate\Database\Eloquent\Collection;
use KhanhArtisan\LaravelBackbone\Eloquent\CollectionVisitorInterface;

class PostCollectionVisitor implements CollectionVisitorInterface
{
    public function apply(Collection $collection): void
    {
        $collection->load('author');
    }
}
protected function indexCollectionVisitors(Request $request): array
{
    return [new PostCollectionVisitor()];
}

Additional Response Data

Add meta data to API resources via the *Additional() hooks:

protected function showAdditional(Request $request, Model $resource): array
{
    /** @var Post $post */
    $post = $resource;

    return [
        'meta' => ['view_count' => $post->views],
    ];
}

For index responses, indexAdditional() receives a GetData instance:

use KhanhArtisan\LaravelBackbone\Eloquent\GetData;

protected function indexAdditional(Request $request, GetData $getData): array
{
    return [
        'meta' => [
            'total' => $getData->total(),
            'count' => $getData->getCollection()->count(),
        ],
    ];
}

Index: Filtering & Pagination

Query scopes

Return an array of scopes from indexQueryScopes(). Keys are identifiers; values are Scope instances or closures (Builder $query, Model $model) => void.

Using a scope class:

php artisan make:scope PostStatusScope
<?php

namespace App\Models\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class PostStatusScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $status = request()->query('status');

        if (!$status || !in_array($status, ['draft', 'published', 'archived'])) {
            return;
        }

        $builder->where('status', $status);
    }
}
protected function indexQueryScopes(Request $request): array
{
    return [
        PostStatusScope::class => new PostStatusScope(),
    ];
}

Using a closure:

protected function indexQueryScopes(Request $request): array
{
    return [
        'post-title' => function (Builder $query, Post $post) use ($request) {
            if ($title = $request->query('title')) {
                $query->where('title', 'like', "%{$title}%");
            }
        },
    ];
}

Custom resource collection

protected function resourceCollectionClass(): string
{
    return PostCollection::class;
}

Custom query executor

Implement GetQueryExecutorInterface to control how records are fetched and counted:

<?php

namespace App\GetQueryExecutors;

use Illuminate\Database\Eloquent\Builder;
use KhanhArtisan\LaravelBackbone\Eloquent\GetData;
use KhanhArtisan\LaravelBackbone\Eloquent\GetQueryExecutorInterface;

class PostQueryExecutor implements GetQueryExecutorInterface
{
    public function execute(Builder $query): GetData
    {
        $paginator = $query->paginate(25);

        return new GetData(
            collect($paginator->items()),
            $paginator->total(),
            ['per_page' => 25]
        );
    }
}
protected function indexGetQueryExecutor(Request $request): GetQueryExecutorInterface
{
    return new PostQueryExecutor();
}

Built-in executors:

Class Behavior
SimplePaginationExecutor paginate()default
DefaultExecutor get() + count() — no pagination

Nested Resources

Nested APIs work with standard Laravel nested routing. Scope child records in indexQueryScopes() and authorize against the parent in each action:

public function index(Request $request, Post $post): ResourceCollection
{
    $this->authorize('view', $post);

    return $this->jsonIndex($request);
}

protected function indexQueryScopes(Request $request): array
{
    return [
        'by-post' => function (Builder $query, Comment $comment) use ($request) {
            $post = $request->route('post');
            $query->where('post_id', $post->id);
        },
    ];
}

public function store(StoreCommentRequest $request, Post $post): CommentResource
{
    $this->authorize('view', $post);

    $data = $request->validated();
    $data['post_id'] = $post->id;

    return $this->jsonStore($request, $data);
}

public function show(Request $request, Post $post, Comment $comment): CommentResource
{
    $this->authorize('view', $post);

    return $this->jsonShow($request, $comment);
}

For show, update, and destroy on nested routes, use Laravel's scoped bindings so the child is automatically constrained to the parent.

Authorization

Authorization is handled in your controller using standard Laravel gates and policies:

public function show(Request $request, Post $post): PostResource
{
    $this->authorize('view', $post);

    return $this->jsonShow($request, $post);
}

Repository

The repository abstracts data access from controller logic. JsonController uses the default KhanhArtisan\LaravelBackbone\Eloquent\Repository unless you override repository().

To customize, extend Repository and implement RepositoryInterface:

<?php

namespace App\Repositories;

use App\Models\Post;
use KhanhArtisan\LaravelBackbone\Eloquent\Repository;
use KhanhArtisan\LaravelBackbone\Eloquent\RepositoryInterface;

class PostRepository extends Repository implements RepositoryInterface
{
    public function __construct()
    {
        parent::__construct(Post::class);
    }

    // Override any RepositoryInterface method as needed
}

Register it in your controller:

protected function repository(): RepositoryInterface
{
    return $this->repository ?? $this->repository = new PostRepository();
}

Available methods

Method Description
modelClass() Returns the bound model class
query() New Eloquent query builder
applyQueryScopes() Apply scopes to a builder
applyResourceVisitors() Run visitors on a single model
applyCollectionVisitors() Run visitors on a collection
find() Find by ID with optional scopes and visitors
get() Fetch a collection via a query executor
create() Create a model with before/after visitors
save() Update a model with before/after visitors
delete() Delete by ID or model instance
massDelete() Delete all records matching a scoped query
withoutEvents() Run a callback without firing model events

Model Listeners

Laravel observers work well for simple cases, but complex domain logic can become hard to maintain in a single class. Model Listeners provide a structured alternative: one class per concern, with explicit event subscriptions and priority ordering.

Setup

1. Mark the model as observable:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use KhanhArtisan\LaravelBackbone\ModelListener\ObservableModel;

class Post extends Model implements ObservableModel
{
    //
}

2. Generate a listener:

php artisan make:model-listener PostNotificationListener --model=Post --events=created,deleted

3. Implement the generated class:

<?php

namespace App\ModelListeners\Post;

use App\Models\Post;
use KhanhArtisan\LaravelBackbone\ModelListener\ModelListener;
use KhanhArtisan\LaravelBackbone\ModelListener\ModelListenerInterface;

class PostNotificationListener extends ModelListener implements ModelListenerInterface
{
    public function priority(): int
    {
        return 0; // Higher values run first
    }

    public function modelClass(): string
    {
        return Post::class;
    }

    public function events(): array
    {
        return ['created', 'deleted'];
    }

    protected function _handle(Post $post, string $event): void
    {
        if ($event === 'created') {
            // Notify subscribers
        }

        if ($event === 'deleted') {
            // Clean up related data
        }
    }
}

4. Verify registration:

php artisan model-listener:show

If the model does not implement ObservableModel, the command warns that listeners may not fire.

Custom model paths

By default, models are discovered in app/Models and listeners in app/ModelListeners. Register additional paths in AppServiceProvider:

use KhanhArtisan\LaravelBackbone\ModelListener\Observer;

Observer::registerModelsFrom(
    $this->app->getNamespace().'CustomModels',
    app_path('CustomModels')
);

Singleton listeners

Override isSingleton() to return false if a fresh listener instance should be created for each event dispatch (default is true).

Relation Cascade

Database-level ON DELETE CASCADE does not work with soft deletes, can lock large tables, and is unavailable on some databases. Relation Cascade performs cascade operations in the application layer, processing records in configurable chunks via queued jobs.

Only models using Laravel's SoftDeletes trait can use this feature.

Migration

Add soft deletes and cascade tracking columns using the provided Blueprint macro:

Schema::create('posts', function (Blueprint $table) {
    $table->id();
    // ... other columns

    $table->softDeletes();
    $table->cascades(); // cascade_status, cascade_updated_at, and index

    $table->index(['cascade_status', 'deleted_at']);
});

The cascades() macro adds:

  • cascade_status — tracks idle, pending delete, pending restore, etc.
  • cascade_updated_at — last cascade state change
  • An index on (cascade_status, cascade_updated_at)

Model configuration

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use KhanhArtisan\LaravelBackbone\RelationCascade\Cascades;
use KhanhArtisan\LaravelBackbone\RelationCascade\CascadeDetails;
use KhanhArtisan\LaravelBackbone\RelationCascade\ShouldCascade;

class Post extends Model implements ShouldCascade
{
    use SoftDeletes, Cascades;

    public function getCascadeDetails(): CascadeDetails|array
    {
        return [
            (new CascadeDetails($this->comments()))
                ->setShouldDelete(true)
                ->setShouldRestore(true)
                ->setShouldForceDelete(false)
                ->setShouldUseTransaction(true)
                ->setShouldDeletePerItem(true),
        ];
    }

    public function autoForceDeleteWhenAllRelationsAreDeleted(): bool
    {
        return false;
    }

    public function comments(): HasMany
    {
        return $this->hasMany(Comment::class);
    }
}
Option Default Description
setShouldDelete() true Cascade soft-delete to related records
setShouldRestore() true Cascade restore when parent is restored
setShouldForceDelete() false Force-delete related records instead of soft-deleting
setShouldUseTransaction() true Wrap each cascade batch in a transaction
setShouldDeletePerItem() true Delete one-by-one (fires events) vs. batch delete

Scheduling

Schedule the background jobs in routes/console.php or your scheduler:

use Illuminate\Support\Facades\Schedule;
use KhanhArtisan\LaravelBackbone\RelationCascade\Jobs\CascadeDelete;
use KhanhArtisan\LaravelBackbone\RelationCascade\Jobs\CascadeRestore;

$recordsLimit = 10000; // max records per job run
$chunkSize = 100;      // records processed per iteration

Schedule::job(new CascadeDelete($recordsLimit, $chunkSize))->everyMinute();
Schedule::job(new CascadeRestore($recordsLimit, $chunkSize))->everyMinute();

When a post is soft-deleted, its comments are queued for cascade deletion. Restoring the post queues a cascade restore.

Custom model paths

use KhanhArtisan\LaravelBackbone\RelationCascade\RelationCascadeManager;

$this->app->make(RelationCascadeManager::class)->registerModelsFrom(
    $this->app->getNamespace().'CustomModels',
    app_path('CustomModels')
);

Counter

The Counter subsystem records high-frequency events (page views, impressions, clicks, etc.) in Redis and periodically persists aggregated data to the database. This avoids write amplification on hot rows during traffic spikes while still giving you queryable historical metrics.

The default recorder driver requires Redis. Ensure Redis is configured as your cache (or counter) connection.

Setup

1. Publish and run the migration:

php artisan vendor:publish --tag=laravel-backbone-counter-migration
php artisan migrate

2. Optionally publish configuration:

php artisan vendor:publish --tag=laravel-backbone-counter-config

Default configuration (config/counter.php):

Key Env variable Default
default_recorder COUNTER_DEFAULT_RECORDER redis
default_store COUNTER_DEFAULT_STORE database
recorders.redis.connection COUNTER_RECORDER_REDIS_CONNECTION cache
recorders.redis.expiration COUNTER_RECORDER_REDIS_EXPIRATION 86400
stores.database.connection COUNTER_STORE_DATABASE_CONNECTION default DB
stores.database.table COUNTER_STORE_DATABASE_TABLE counter

How It Works

Counter has two layers:

Layer Facade Role
Recorder Recorder Hot path — increments counts in Redis with minimal latency
Store Store Cold path — durable storage in the counter table for querying and roll-ups
Request → Recorder::record() → Redis (sharded by time bucket)
                                      ↓
                          StoreData job (scheduled)
                                      ↓
                          Store::upsert() → counter table
                                      ↓
                          Store::getRecord() / getRecordsByTime() / …

Counts are grouped into time buckets determined by the Interval you choose. Each bucket is identified by a normalized Unix timestamp (the bucket start time). Use TimeHelper::startTime() when you need to compute the bucket for a given moment:

use KhanhArtisan\LaravelBackbone\Contracts\Counter\Interval;
use KhanhArtisan\LaravelBackbone\Contracts\Counter\TimeHelper;

$bucketStart = TimeHelper::startTime(Interval::HOURLY);           // current hour
$bucketStart = TimeHelper::startTime(Interval::DAILY, strtotime('2024-06-01')); // start of that day

Data is only queryable from the Store after the StoreData job has flushed the corresponding Redis bucket. Schedule that job at or below your recording interval (see Scheduling).

Recording Events

Use the Recorder facade on the write path:

use KhanhArtisan\LaravelBackbone\Contracts\Counter\Interval;
use KhanhArtisan\LaravelBackbone\Support\Facades\Counter\Recorder;

public function show(Request $request, Post $post): PostResource
{
    Recorder::record(
        partitionKey: 'post-views',
        interval: Interval::ONE_MINUTE,
        reference: $post->id,
        value: 1,
    );

    return $this->jsonShow($request, $post);
}
Parameter Description
partitionKey Logical grouping for related counters (e.g. 'post-views', 'site-visits')
interval Time bucket granularity used for recording and querying
reference Entity identifier (e.g. post ID, user ID, 'global')
value Increment amount (default 1)
shardSize Max references per Redis shard (default 1000)
eventTime Unix timestamp override (default: now)

Available intervals

Constant Bucket size
Interval::ONE_MINUTE 1 minute
Interval::FIVE_MINUTES 5 minutes
Interval::TEN_MINUTES 10 minutes
Interval::FIFTEEN_MINUTES 15 minutes
Interval::THIRTY_MINUTES 30 minutes
Interval::HOURLY 1 hour
Interval::DAILY 1 day
Interval::WEEKLY 1 week
Interval::MONTHLY 1 month
Interval::YEARLY 1 year

Shorter intervals give fresher data but more rows and more frequent job runs. Longer intervals are better for archival metrics and dashboards that do not need minute-level precision.

Querying Records

All read operations go through the Store facade against the persisted counter table:

use KhanhArtisan\LaravelBackbone\Support\Facades\Counter\Store;

Each query returns Record instances with:

Method Description
getPartitionKey() Partition the record belongs to
getInterval() Interval enum for the bucket
getTime() Bucket start as Unix timestamp
getReference() Entity identifier
getValue() Count for that bucket
getStoreReference() ULID primary key in the counter table

getRecord() — single count for one entity in one bucket

Fetch the count for a specific reference at a specific time bucket. Returns null if no record exists.

use KhanhArtisan\LaravelBackbone\Contracts\Counter\Interval;
use KhanhArtisan\LaravelBackbone\Contracts\Counter\TimeHelper;

$views = Store::getRecord(
    partitionKey: 'post-views',
    interval: Interval::HOURLY,
    time: TimeHelper::startTime(Interval::HOURLY),
    reference: $post->id,
);

$count = $views?->getValue() ?? 0;

time is normalized automatically — you can pass time() or any timestamp within the bucket.

getRecordsByReference() — time series for one entity

Fetch all buckets for a single reference within a time range. Results are ordered by time ascending.

$from = now()->subDays(7)->getTimestamp();
$to = now()->getTimestamp();

$records = Store::getRecordsByReference(
    partitionKey: 'post-views',
    interval: Interval::DAILY,
    reference: $post->id,
    fromTime: $from,
    toTime: $to,
);

// Build chart data: [['date' => '2024-06-01', 'views' => 142], ...]
$chartData = collect($records)->map(fn ($record) => [
    'date' => date('Y-m-d', $record->getTime()),
    'views' => $record->getValue(),
]);

getRecordsByTime() — all entities in one bucket (leaderboard)

Fetch every reference counted in a single time bucket, sorted by value. Useful for "top posts this hour" or "most active users today".

$records = Store::getRecordsByTime(
    partitionKey: 'post-views',
    interval: Interval::HOURLY,
    time: TimeHelper::startTime(Interval::HOURLY),
    limit: 10,
    sort: 'desc', // highest counts first
);

$postIds = collect($records)->map(fn ($record) => $record->getReference());
$posts = Post::whereIn('id', $postIds)->get()->keyBy('id');

$leaderboard = collect($records)->map(fn ($record) => [
    'post' => $posts[$record->getReference()] ?? null,
    'views' => $record->getValue(),
]);

Cursor pagination — pass the previous page's last record ID as cursorId to fetch the next page without offset scans:

$cursorId = null;

do {
    $page = Store::getRecordsByTime(
        partitionKey: 'post-views',
        interval: Interval::DAILY,
        time: TimeHelper::startTime(Interval::DAILY, strtotime('2024-06-01')),
        limit: 50,
        sort: 'desc',
        cursorId: $cursorId,
    );

    foreach ($page as $record) {
        // process $record
    }

    $cursorId = $page ? end($page)->getStoreReference() : null;
} while (count($page) === 50);

Store API reference

Method Purpose
getRecord() Single count for one reference in one bucket
getRecordsByReference() Time series for one reference across a range
getRecordsByTime() All references in one bucket, with optional cursor pagination
getRecordsAndExecuteOnce() Fetch unprocessed records and mark them executed (for syncing)
rollupIntervalRecords() Aggregate finer intervals into coarser ones
upsert() Manually write or increment records
delete() Delete records by store ULID
prune() Delete records older than a given time

Counter Examples

Display view count in a JSON API response

use KhanhArtisan\LaravelBackbone\Contracts\Counter\Interval;
use KhanhArtisan\LaravelBackbone\Contracts\Counter\TimeHelper;
use KhanhArtisan\LaravelBackbone\Support\Facades\Counter\Recorder;
use KhanhArtisan\LaravelBackbone\Support\Facades\Counter\Store;

public function show(Request $request, Post $post): PostResource
{
    Recorder::record('post-views', Interval::ONE_MINUTE, $post->id);

    // Total views today (persisted counts only)
    $today = Store::getRecord(
        'post-views',
        Interval::DAILY,
        TimeHelper::startTime(Interval::DAILY),
        $post->id,
    );

    return $this->jsonShow($request, $post)
        ->additional(['meta' => ['views_today' => $today?->getValue() ?? 0]]);
}

Top 10 posts in the current hour

$records = Store::getRecordsByTime(
    partitionKey: 'post-views',
    interval: Interval::HOURLY,
    time: TimeHelper::startTime(Interval::HOURLY),
    limit: 10,
    sort: 'desc',
);

return Post::whereIn('id', collect($records)->map->getReference())
    ->get()
    ->sortByDesc(fn ($post) => collect($records)
        ->first(fn ($r) => $r->getReference() === (string) $post->id)?->getValue() ?? 0
    )
    ->values();

7-day view chart for a post

$records = Store::getRecordsByReference(
    partitionKey: 'post-views',
    interval: Interval::DAILY,
    reference: $post->id,
    fromTime: now()->subDays(6)->startOfDay()->getTimestamp(),
    toTime: now()->getTimestamp(),
);

return collect($records)->map(fn ($r) => [
    'date' => date('Y-m-d', $r->getTime()),
    'views' => $r->getValue(),
]);

Aggregate total views across all time buckets for a post

When you need a single total rather than per-bucket counts, sum the daily records:

$records = Store::getRecordsByReference(
    partitionKey: 'post-views',
    interval: Interval::DAILY,
    reference: $post->id,
    fromTime: 0,
    toTime: now()->getTimestamp(),
);

$totalViews = collect($records)->sum(fn ($r) => $r->getValue());

For live totals that include not-yet-flushed Redis data, keep a denormalized column on your model and sync via getRecordsAndExecuteOnce() (see below).

Syncing to Application Tables

getRecordsAndExecuteOnce() fetches records that have not yet been processed, runs your callback, then marks them as executed so they are never returned again. Use this to copy counter values into your main tables (e.g. a posts.views column).

use KhanhArtisan\LaravelBackbone\Contracts\Counter\Interval;
use KhanhArtisan\LaravelBackbone\Support\Facades\Counter\Store;

Store::getRecordsAndExecuteOnce(
    partitionKey: 'post-views',
    interval: Interval::ONE_MINUTE,
    limit: 500,
    executor: function (array $records, \Closure $markExecuted) {
        foreach ($records as $record) {
            Post::where('id', $record->getReference())
                ->increment('views', $record->getValue());
        }

        // Mark all fetched records as executed — required
        $markExecuted();
    },
);

Schedule this in a job alongside StoreData:

Schedule::call(function () {
    Store::getRecordsAndExecuteOnce('post-views', Interval::ONE_MINUTE, 1000, function ($records, $done) {
        foreach ($records as $record) {
            Post::where('id', $record->getReference())->increment('views', $record->getValue());
        }
        $done();
    });
})->everyMinute();

Always call $markExecuted() (or $done()) inside the executor when processing succeeds. Records left unmarked will be returned on the next run.

Roll-up & Pruning

Roll up to coarser intervals

Combine fine-grained buckets into coarser ones (e.g. 1-minute → 5-minute → hourly). Rolled-up source records are flagged and will not be rolled up again.

// Roll completed 1-minute buckets into 5-minute buckets
Store::rollupIntervalRecords(
    fromInterval: Interval::ONE_MINUTE,
    toInterval: Interval::FIVE_MINUTES,
    partitionKey: 'post-views',
    limit: 1000,
);

// Roll into multiple targets at once
Store::rollupIntervalRecords(
    fromInterval: Interval::FIVE_MINUTES,
    toInterval: [Interval::HOURLY, Interval::DAILY],
    partitionKey: 'post-views',
);

Only records whose time bucket has fully elapsed are eligible for roll-up.

Delete and prune

// Delete specific records by store ULID
Store::delete($record->getStoreReference());
Store::delete([$id1, $id2]);

// Remove all daily records at or before a timestamp
Store::prune(Interval::DAILY, now()->subMonths(3)->getTimestamp(), 'post-views');

// Prune with a row limit per run (useful for large tables)
Store::prune(Interval::DAILY, now()->subMonths(3)->getTimestamp(), 'post-views', limit: 1000);

Scheduling Background Jobs

Three jobs cover the full counter lifecycle:

use KhanhArtisan\LaravelBackbone\Contracts\Counter\Interval;
use KhanhArtisan\LaravelBackbone\Counter\Jobs\ClearData;
use KhanhArtisan\LaravelBackbone\Counter\Jobs\StoreData;
use KhanhArtisan\LaravelBackbone\Support\Facades\Counter\Store;

// 1. Flush Redis → database (run at or below your recording interval)
Schedule::job(new StoreData('post-views', Interval::ONE_MINUTE))->everyMinute();

// 2. Optionally roll up 1m → 5m → hourly (after StoreData has run)
Schedule::call(function () {
    Store::rollupIntervalRecords(
        Interval::ONE_MINUTE,
        Interval::FIVE_MINUTES,
        'post-views',
    );
})->everyFiveMinutes();

// 3. Prune old counter rows (optional)
Schedule::job(new ClearData(
    interval: Interval::DAILY,
    olderThanTime: now()->subMonths(3),
    partitionKey: 'post-views',
))->daily();
Job / call When to run Purpose
StoreData Every minute (or matching interval) Move counts from Redis to the counter table
rollupIntervalRecords() After StoreData Compress fine-grained rows into coarser intervals
ClearData Daily / weekly Remove expired counter rows

Testing

The package includes JsonApiTest for end-to-end CRUD feature tests against JSON API endpoints.

php artisan make:test PostApiTest
<?php

namespace Tests\Feature;

use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use KhanhArtisan\LaravelBackbone\Testing\JsonApiTest;
use KhanhArtisan\LaravelBackbone\Testing\JsonCrudTestData;
use Tests\TestCase;

class PostApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_basic_crud(): void
    {
        $testData = (new JsonCrudTestData())
            ->setStoreData(['title' => Str::random()])
            ->setUpdateData(['title' => Str::random()])
            ->actingAs(User::factory()->create());

        (new JsonApiTest($this))->testBasicCrud('/api/posts', $testData);
    }
}

testBasicCrud() runs store → update → index → show → destroy in sequence, asserting response codes and data by default.

Customizing expectations

Method Default Description
setStoreData() Payload for POST (required)
setUpdateData() Payload for PATCH (required)
actingAs() guest Authenticated user
setExpectedStoreResponseCode() 201 Expected POST status
setExpectedUpdateResponseCode() 200 Expected PATCH status
setExpectedIndexResponseCode() 200 Expected GET index status
setExpectedShowResponseCode() 200 Expected GET show status
setExpectedDestroyResponseCode() 200 Expected DELETE status
setExpected*ResponseData() auto-match Override expected JSON body

Set custom URIs per action (setStoreUri(), setUpdateUri(), etc.) when testing nested or non-standard routes.

Artisan Commands

Command Description
make:model-listener Scaffold a model listener class
model-listener:show List registered listeners and their priorities

make:model-listener options

php artisan make:model-listener PostNotificationListener \
    --model=App\\Models\\Post \
    --events=created,updated,deleted \
    --path=ModelListeners\\Post
Option Description
--model Fully qualified model class
--events Comma-separated Eloquent events
--path Namespace path under app/

Contributing

Bug reports, feature requests, and pull requests are welcome. Please open an issue before submitting large changes so we can discuss the approach.

When contributing:

  1. Follow existing code style and conventions
  2. Add or update tests for behavioral changes
  3. Run the test suite before submitting:
composer test

License

This package is open-source software licensed under the MIT license.

统计信息

  • 总下载量: 347
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 6
  • 点击次数: 6
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 6
  • Watchers: 2
  • Forks: 1
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2023-06-01

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固