# eloquence: drop-in extensions for Laravel's Eloquent

> Eloquence is a package of traits and attributes that bolt onto Laravel's Eloquent models: models that can be loaded but never written, attributes that are camel cased in your code while staying snake cased in the database, and cached counts and sums that keep a sortable aggregate on the record instead of computing it in a query. It is the kind of package that exists because the features are useful and the framework will never take them.

**kirkbushell/eloquence** — A drop-in library for certain database functionality in Laravel, that allows for extra features that may never make it into the main project.

- Repository: https://github.com/kirkbushell/eloquence
- Stars: 575 · Forks: 56
- Language: PHP
- License: MIT
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/kirkbushell-eloquence

## What the package is

Eloquence is a package to extend Laravel's base Eloquent models and functionality. It provides a number of utilities and attributes to work with Eloquent in new and useful ways, such as camel cased attributes for JSON apis and code style cohesion, data aggregation, and more.

The repository description puts the reason for its existence plainly: it is a drop-in library for certain database functionality in Laravel that allows for extra features that may never make it into the main project. That framing matters, because it tells you what kind of library you are installing. Nothing here changes how the framework works, and nothing here is required by the framework. It is a set of traits you add to your models.

Installation is a single composer command, and the package is discovered automatically by Laravel, with no further steps. For users on earlier versions of the framework there is the older route of adding the service provider to the application configuration file. And the readme is honest about what that provider does: very little, other than enabling the query log if it is configured.

That last sentence tells you something about the design. The package is a set of behaviours rather than a piece of infrastructure, and the behaviours attach to individual models through traits. Nothing is global, nothing is configured once for the whole application, and removing a trait removes the feature.

The four behaviours are read-only models, camel cased attributes, count caching and sum caching. The first two are conveniences with a clear shape. The second pair are a data modelling strategy with a performance argument behind them, and they are where the design decisions are worth examining, because the aggregation cache is the feature that most needs justifying and most needs care in use.

## Models you can read but not write

The read-only behaviour protects a model by ensuring that it can only be loaded from the database, and cannot be written to or have its values changed.

The stated use is data you do not wish to be altered, or cases where you may be sharing models across domain boundaries. That second case is the more interesting one. A model that is passed between two parts of an application, where one of them should be able to read it but not change it, is a case where the rule belongs on the model rather than in a code review. A trait on the class makes the restriction a property of the type, and it applies to every path that uses that type rather than only to the paths somebody remembered.

Using it is one trait on the model.

```php
use \Eloquence\Behaviours\Readonly\HasReadOnly;

class Log extends Model {
    use HasReadOnly;
}
```

And that is the whole interface. There is no configuration, no list of protected attributes, and no partial mode. The trait either applies to the model or it does not.

The design choice worth noticing is that the restriction is enforced by the model layer and not by the database. A read-only model in the sense of a database view or a permission would be a stronger guarantee, because it holds against a direct query, a migration, a console command, or a second application. The trait holds against the application code that uses the model, and against nothing else. The readme does not oversell it, describing it as protection of models rather than as protection of data, and the two are different claims.

For a log record that should never change, or a value handed across a boundary where the receiving side is not supposed to alter it, the trait does the job the author intended. For a guarantee you would want to survive the application being rewritten, it is not enough on its own.

## Camel casing in code, snake casing in the database

The camel casing behaviour exists for people who prefer a single coding style across their whole application. The trait transforms all attribute access from camel case to snake case in real time, so that everything from attribute access to JSON API responses is camel cased.

That is a real annoyance in a Laravel application, and the reasoning is easy to reconstruct. The database schema uses snake case, because that is the convention in the framework, and the JSON your API returns might be camel cased, because that is the convention on the wire. Then a model has an attribute called, say, created at, and every time you touch it you have to remember which convention applies where. The trait removes the decision by fixing one side of it.

Using it is one trait.

And then there is the note, and the note is the most important paragraph in the readme because it is a warning about what the trait does not do. Eloquence does not change how you write your schema migrations. You should still be using snake case when setting up your columns and tables in your database schema migrations.

The readme's reason is worth repeating because it is a design stance rather than a limitation: snake case for column names is the de facto standard within the Laravel community and is widely used across database schemas, so the package deliberately leaves the schema alone.

That is the right call. A tool that rewrote your migrations would be a tool that made your database schema follow your code style, and the moment somebody joined the project from a different background, or a tool read your schema, or you exported it, you would have a problem that did not exist before. Keeping the transformation at the model boundary means the database stays conventional, the API stays consistent, and the difference between the two is handled in exactly one place.

So the trait is a translation layer at the edge of your application, and its value is that it is a layer you can see and reason about rather than a convention you have to remember.

## Why cache a count at all

The count cache caches the result of a count on a related model's record, and the example is the one everybody has: posts that belong to authors, where you want to count the posts an author has and perhaps order by that count.

The reason is a database one, and it is stated in the readme. In SQL, ordering by an aggregated value cannot be indexed, and is therefore slow. The way around it is to cache the count of the posts the author has created on the author's model record.

That is the whole argument, and it is a good one. A list of authors ordered by how many posts they have is a query that a database cannot help with, because the ordering depends on the aggregate rather than on a column. Caching the number on the row turns it back into an indexed sort on a column, and the difference between those two queries is the difference between a page that loads and a page that times out.

The cost is that the cached number has to be maintained, and that is what the behaviour machinery does. Setting it up takes two steps: use the counting trait on the child model, and mark the relationship with the counting attribute.

```php
use Eloquence\Behaviours\CountCache\CountedBy;
use Eloquence\Behaviours\CountCache\HasCounts;
use Illuminate\Database\Eloquent\Model;

class Post extends Model {
    use HasCounts;

    #[CountedBy]
    public function author(): BelongsTo
    {
        return $this->belongsTo(Author::class);
    }
}
```

And the mechanism is the part that makes this pleasant to use. Marking the relationship tells the behaviour that the child model has an aggregate count cache on the parent, so whenever a post is added, modified or deleted, the behaviour updates the appropriate parent's count. In the example it would update the post count field on the author model.

The convention is that post count is a defined field on the author model table, and the readme is explicit that the field is a field on your own table. And because it uses your own relationship to find the related record, no other configuration is required.

That last point is what makes the design clean. The behaviour does not need a separate configuration of which foreign key to follow or which column to write, because it reads the relationship you already declared. The aggregate column is the only thing you have to agree with, and it is a naming convention rather than a setting.

If your field is called something else, you say so with a named argument on the attribute, and because it is a PHP attribute you get the named argument syntax from the language itself.

```php
class Post extends Model {
    use HasCounts;

    #[CountedBy(as: 'total_posts')]
    public function author(): BelongsTo
    {
        return $this->belongsTo(Author::class);
    }
}
```

And there is no limit of one per model. You can mark as many relationships as you need, each with its own aggregate column, so a post can maintain a count on its author and a count on its category at the same time.

## Sums, and the extra guidance they need

Sum caching is the same idea with a sum instead of a count. The example is an order with many items, where you want the sum of the item prices cached on the order rather than computed every time.

The setup is the same two steps, with the summing trait on the child model and the summing attribute on each relationship that needs it.

```php
use Eloquence\Behaviours\SumCache\HasSums;
use Eloquence\Behaviours\SumCache\SummedBy;
use Illuminate\Database\Eloquent\Model;

class Item extends Model {
    use HasSums;

    #[SummedBy(from: 'amount', as: 'total_amount')]
    public function order(): BelongsTo
    {
        return $this->belongsTo(Order::class);
    }
}
```

And the difference from the count case is stated in one sentence: unlike the count cache, which can assume sensible defaults, the sum cache needs a bit more guidance. The attribute takes the name of the field on the child to sum and the name of the field on the parent to store the total in. So the item's amount column is summed into the order's total amount column.

The reason for the extra guidance is easy to see. A count is unambiguous: there is one number and it goes in one place. A sum needs to know which column is being added up, and there is no convention that could guess it. The count cache can default to a name derived from the relation because the relation name tells you the thing being counted. The sum cache cannot, because a relation to an order does not tell you which of the order's line items holds the money.

The machinery underneath is the same, though. The behaviour hooks the model's own save and delete events and writes the aggregate back to the parent, using the relationship you declared to find the parent record. So the two caches are two instances of one mechanism, with different amounts of configuration because one of them has a real ambiguity in it and the other does not.

## Transactions, and what the cache system costs you

The last section of the readme is the most cautionary, and it is the one to read before enabling either cache.

Because the cache system works directly with other model objects and requires multiple writes to the database, it is strongly recommended that you wrap your model saves that use caches in a transaction. In databases like Postgres this is automatic, but for databases like MySQL you need to make sure you are using a transactional database engine like InnoDB.

The reason given is the failure mode. If any one of your queries fails, the caches will end up out of sync.

That is the whole risk of the technique, stated plainly, and it is worth being precise about the shape of it. A cached count is a denormalisation: the row on the parent carries a number that is derived from rows elsewhere. Keeping it correct means every write to the child has to be accompanied by a write to the parent. Those are two writes, and if the second one fails while the first one succeeds, the two have diverged and there is no database constraint that will notice, because the number is not derived by the database, it is derived by the application.

A transaction is what closes that window. Both writes commit or neither does, and the count cannot be wrong relative to the rows unless the application itself is inconsistent.

The MySQL note is not pedantry. The default storage engine in older MySQL installations was not transactional, so on a database that is not using a transactional engine, the recommendation in the readme is not achievable at all, and the cache will drift. That is a deployment fact to check rather than a detail to skim.

The repository itself is small and conventional. There is the source directory, the tests, a configuration directory, the composer manifest, the test configuration, the licence, and a continuous integration directory.

The release history is worth a final note, because it is a sequence of major version bumps, one per year, which is the pattern of a package that tracks the major versions of the framework it extends. That is what you would expect of a package whose value is entirely in staying in step with the framework's own releases, and it is the thing to check when you install: a version of the package built for an older framework generation will not behave the way its documentation describes.

## Conclusion

Eloquence fits a Laravel team that wants one of these four behaviours across many models without writing the trait itself, and that is comfortable with the trade each behaviour makes: read-only models are protected at the application level rather than by the database, camel casing is a code style decision that the schema deliberately does not follow, and the caches are writes that have to be transactional. It does not fit a project that wants these guarantees enforced by the database itself. Verify first that the column names your caches write to actually exist, because the defaults are conventions rather than configuration, that your tables are on a transactional engine if you are on MySQL, and that the read-only protection covers the paths you care about, since it is applied by the model rather than by the database.

## FAQ

### What does the eloquence package do for Laravel?

It provides a set of traits and attributes that extend Eloquent models: read-only models that can be loaded but not written, camel cased attribute access with camel cased JSON output, count caches that keep a sortable aggregate on the parent record, and sum caches that keep a running total on the parent record.

### How do I use the read-only behaviour in eloquence?

Add the read-only trait to the model class. The model can then only be loaded from the database, not written to and not have its values changed. The readme suggests it for data you do not want altered and for models shared across domain boundaries, and the protection is applied by the model rather than by the database.

### Does the camel casing behaviour change my database schema?

No, and the readme is emphatic about it. The trait transforms attribute access and JSON output into camel case at the model boundary, but you should still write your schema migrations in snake case, because that is the convention across the Laravel community and widely used in database schemas.

### Why does eloquence recommend caching a count instead of counting in the query?

Because ordering by an aggregated value in SQL cannot be indexed and is therefore slow. Caching the count on the parent record turns the ordering back into an indexed sort on a column. You mark the relationship with the counting attribute and the behaviour keeps the aggregate up to date as child records are added, changed, and deleted.

### What do I have to be careful about when using the count and sum caches?

The cache system writes to other model objects and needs multiple database writes, so model saves that use the caches should be wrapped in a transaction. On Postgres that is automatic; on MySQL you need a transactional engine such as InnoDB. Without a transaction, a failure between the two writes leaves the cached aggregate out of sync with the rows it was derived from.

## Sources

- [Issues](https://github.com/kirkbushell/eloquence/issues)
- [kirkbushell/eloquence on GitHub](https://github.com/kirkbushell/eloquence)
- [License: MIT](https://github.com/kirkbushell/eloquence/blob/master/LICENSE)
- [README](https://github.com/kirkbushell/eloquence/blob/master/README.md)
- [Releases](https://github.com/kirkbushell/eloquence/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/kirkbushell-eloquence
