beziworld / laravel-ml
Laravel-first machine learning on Rubix ML: Eloquent datasets, model classes, versioned persistence, queued training, predictions and php-ml style tools
Requires
- php: ^8.3
- ext-json: *
- ext-zlib: *
- illuminate/bus: ^12.0|^13.0
- illuminate/console: ^12.0|^13.0
- illuminate/container: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/events: ^12.0|^13.0
- illuminate/filesystem: ^12.0|^13.0
- illuminate/queue: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/validation: ^12.0|^13.0
- rubix/ml: ^3.3
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.7|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- phpstan/phpstan-strict-rules: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-04 21:38:39 UTC
README
Laravel-first machine learning in plain PHP. Everything php-ml offered (classification, regression, clustering, Apriori, cross validation, TF-IDF, preprocessing, metrics, persistence, neural networks), built on the maintained Rubix ML 3 engine and wrapped in the things a Laravel application actually needs:
- datasets straight from Eloquent (
fromQuery()streams withlazy()), collections, arrays and CSV files - feature casting: numbers, categories, booleans, dates, free text; imputation, one-hot encoding, normalisation, TF-IDF
- model definitions as classes (
ChurnModel extends ClassificationModel) or an ad-hoc fluent builder - presets: classifier, regressor, clusterer, anomaly detector, KNN recommender, text classifier (spam / sentiment)
- Apriori association rules over Laravel collections (Rubix has none, php-ml had one)
- holdout and k-fold cross validation with a typed
TrainingReport(accuracy, precision, recall, F1, MCC, confusion matrix, MAE, RMSE, R², silhouette, V-measure) - versioned models on any filesystem disk (
v1,v2, ...), metadata JSON, promotion, rollback, pruning - signed model files: SHA-256 + HMAC verification and a restricted
allowed_classeslist beforeunserialize() - predictions as JSON-friendly DTOs,
HasPredictionstrait,AsPredictioncast,ClassifiedAsandNotAnomalousvalidation rules - queued training (
TrainModeljob), events, Artisan commands, a data drift check for nightly monitoring
Why this package
| php-ml | Rubix ML alone | laravel-ml | |
|---|---|---|---|
| Maintained, PHP 8.3+ | no (abandoned, last fork push 2023) | yes | yes (Rubix 3.3) |
| Algorithms | ~25 | 40+ | Rubix + Apriori + silhouette |
| Eloquent / collections as data source | no | no | yes, streamed |
| Feature casting (dates, enums, text, missing values) | manual | manual | declarative |
| Model versions, promotion, rollback | no | no | yes |
| Safe loading of model files | no | crc32 checksum only | SHA-256 + HMAC + class allowlist |
| Queue, events, Artisan, validation rules, casts | no | no | yes |
Existing Laravel packages (checked on Packagist and GitHub in November 2026): a search for php-ml laravel returns
nothing; torian257x/ai-php-rubix-wrap-laravel (72 downloads, last change 2022) is a thin wrapper without
persistence, versioning or validation; divineomega/eloquent-attribute-value-prediction predicts a single Eloquent
attribute. Neither covers cross validation reports, versioned storage, text classification, recommendations,
association rules or drift checks.
Installation
composer require beziworld/laravel-ml
php artisan vendor:publish --tag=ml-config # optional
Requirements: PHP 8.3+, Laravel 12 or 13, ext-zlib. The optional
Tensor extension speeds up linear models and neural networks considerably.
Everything works without configuration. Optional .env settings:
ML_DISK=local # filesystem disk for model files ML_PATH=ml-models # directory on that disk ML_AUTO_PROMOTE=true # activate every freshly trained version ML_KEEP_VERSIONS=5 # versions kept by ml:prune ML_VALIDATION=holdout # holdout, kfold or none ML_HOLDOUT_RATIO=0.2 ML_FOLDS=5 ML_SEED=42 # reproducible training (mt_srand) ML_MEMORY_LIMIT=2G # applied while training ML_TIME_LIMIT=1800 # set_time_limit() and the queued job timeout ML_QUEUE=ml # queue of the TrainModel job ML_QUEUE_CONNECTION=redis ML_SIGNING_KEY= # HMAC key for model files, defaults to APP_KEY ML_PREDICTION_EVENTS=false # dispatch PredictionMade ML_PREDICTION_SAMPLE_RATE=0.05
Short aliases for Artisan commands go to config/ml.php: 'models' => ['churn' => App\Ml\ChurnModel::class].
Quick start: churn prediction
namespace App\Ml; use App\Models\User; use Beziworld\Ml\Dataset\Feature; use Beziworld\Ml\Models\ClassificationModel; use Illuminate\Database\Eloquent\Builder; final class ChurnModel extends ClassificationModel { public function features(): array { return [ 'plan' => 'categorical', 'age', // type inferred from the data 'monthly_spend' => 'numeric', 'last_login_at' => 'date', // days since epoch Feature::numeric('orders', fn (User $user) => $user->orders_count), ]; } public function label(): string { return 'churned'; // bool attribute, classes "true" / "false" } public function query(): Builder { return User::query()->withCount('orders')->whereNotNull('churned'); } }
use App\Ml\ChurnModel; use Beziworld\Ml\Facades\Ml; use Beziworld\Ml\Training\Validation; $report = Ml::train(ChurnModel::class); // holdout validation by default $report = Ml::train(ChurnModel::class, validation: Validation::kFold(5)); $report->version; // "v3" $report->metric('accuracy'); // 0.93 $report->metric('f1'); $report->confusionMatrix; // ['true' => ['true' => 41, 'false' => 3], 'false' => [...]] (actual => predicted) $prediction = Ml::predict(ChurnModel::class, $user); $prediction->value; // "true" $prediction->is(true); // true $prediction->probabilities; // ['true' => 0.87, 'false' => 0.13] $prediction->confidence; // 0.87 $prediction->toArray(); // JSON-friendly, ready for an API response Ml::predictMany(ChurnModel::class, User::query()->active()->cursor()); // Collection<Prediction>
The default classifier is a random forest. Override estimator() to choose another one, either a Rubix object or a
preset name from Estimators:
use Beziworld\Ml\Models\Estimators; use Rubix\ML\Classifiers\KNearestNeighbors; public function estimator(): Learner&Estimator { return new KNearestNeighbors(7, weighted: true); // or Estimators::classifier('naive_bayes') }
estimator() and transformers() are called once per cross validation fold, so always return new instances.
Quick start: spam filter (text classification)
use Beziworld\Ml\Models\TextClassificationModel; final class SpamFilter extends TextClassificationModel { public function text(): string { return 'body'; } public function label(): string { return 'is_spam'; } public function stopWords(): string { return 'english'; } // or 'polish', 'french', or a list public function ngrams(): int { return 2; } // words and word pairs public function query(): Builder { return Message::query()->whereNotNull('is_spam'); } }
The text is lower-cased, stop words are removed, tokens are counted (WordCountVectorizer) and weighted with TF-IDF
before a softmax classifier is trained. Use it in validation:
use Beziworld\Ml\Rules\ClassifiedAs; $request->validate([ 'body' => ['required', new ClassifiedAs(SpamFilter::class, 'false', minConfidence: 0.7)], ]);
Quick start: product recommendations
use Beziworld\Ml\Models\RecommendationModel; final class SimilarProducts extends RecommendationModel { public function features(): array { return ['category_id' => 'categorical', 'price' => 'numeric', 'rating' => 'numeric', 'description' => 'text']; } public function query(): Builder { return Product::query()->where('active', true); } public function neighbours(): int { return 4; } } Ml::train(SimilarProducts::class); $product->recommendations(SimilarProducts::class); // Collection<Recommendation>, the product itself excluded // [Recommendation{key: 18, distance: 0.21, similarity: 0.83}, ...]
Items are indexed by their transformed feature vectors in a ball tree; the nearest items are recommended. For "customers who bought X also bought Y" use Apriori (below).
Quick start: anomaly detection on orders
use Beziworld\Ml\Models\AnomalyDetectionModel; final class OrderAnomalies extends AnomalyDetectionModel { public function features(): array { return ['total' => 'numeric', 'items_count' => 'numeric', 'country' => 'categorical', 'created_at' => 'date']; } public function contamination(): ?float { return 0.02; } // expected share of anomalies public function query(): Builder { return Order::query()->where('created_at', '>', now()->subYear()); } } $order->isAnomalous(OrderAnomalies::class); // bool Ml::predict(OrderAnomalies::class, $order)->score; // higher means more unusual $request->validate(['order' => ['array', new NotAnomalous(OrderAnomalies::class)]]);
A label is optional: when the query returns a label() (e.g. a confirmed fraud flag), the report adds accuracy,
precision, recall and F1 next to the anomaly rate.
Quick start: customer segmentation
use Beziworld\Ml\Models\ClusteringModel; final class CustomerSegments extends ClusteringModel { public function features(): array { return ['recency_days' => 'numeric', 'orders' => 'numeric', 'revenue' => 'numeric']; } public function clusters(): int { return 4; } public function query(): Builder { return Customer::query(); } } $report = Ml::train(CustomerSegments::class); $report->metric('silhouette'); // -1..1, higher is better separated Ml::predict(CustomerSegments::class, $customer)->value; // cluster number, e.g. 2
Ad-hoc models
No class needed for quick experiments:
$report = Ml::model('lead-scoring') ->classifier('random_forest') // preset, Rubix object or factory closure ->features(['source' => 'categorical', 'visits' => 'numeric']) ->label('converted') ->using(Ml::dataset()->fromQuery(Lead::query())) ->train(); Ml::predict('lead-scoring', ['source' => 'ads', 'visits' => 7]); // stored models are found by name
Presets: classifier() (knn, naive_bayes, gaussian_nb, tree, random_forest, adaboost, bagging,
logistic, softmax, mlp), regressor() (ridge, least_squares, knn, tree, gradient_boost, adaline,
mlp), clusterer() (kmeans, dbscan, fuzzy_cmeans, gaussian_mixture, mean_shift), anomalyDetector()
(isolation_forest, local_outlier_factor, loda, gaussian_mle, robust_zscore), recommender(),
textClassifier(). Neural networks: Estimators::neuralNetwork([64, 32], epochs: 200).
Datasets and preprocessing
$dataset = Ml::dataset() ->fromQuery(User::query(), features: ['age', 'plan', 'signed_up_at'], label: 'churned', chunk: 500) ->get(); Ml::dataset()->fromCollection($rows, ['area', 'rooms'], 'price'); Ml::dataset()->fromCsv(storage_path('houses.csv'), ['area', 'rooms'], 'price', delimiter: ';'); // streamed Ml::dataset()->fromArray([[1, 3], [1, 4], [2, 4]], ['a', 'a', 'b']); // php-ml style $dataset->count(); $dataset->statistics(); $dataset->hash(); $dataset->toArray();
| Type | Accepted values | Sample value | Missing |
|---|---|---|---|
numeric |
int, float, numeric string, bool, int-backed enum | float | NAN |
categorical |
string, int, bool, enum, Stringable | string | "?" |
boolean |
bool, 0/1, "yes"/"no", "true"/"false", "on"/"off" | 1.0 / 0.0 | NAN |
date |
DateTimeInterface (Carbon), date string, Unix timestamp | days since epoch | NAN |
text |
string, array of strings | token counts / TF-IDF | "" |
The automatic preprocessing (per feature group, built from the schema) imputes missing values (mean / most likely
category), normalises numeric columns (zscore by default, minmax, robust, maxabs), one-hot encodes categories
and turns text into TF-IDF weighted token counts. Configure it per model or per dataset:
use Beziworld\Ml\Preprocessing\Preprocessing; public function preprocessing(): ?Preprocessing { return Preprocessing::defaults()->withNormalization('minmax')->withText(tfidf: true, stopWords: 'polish', ngrams: 2); } // or take full control with Rubix transformers: public function transformers(): ?array { return [new MissingDataImputer(), new OneHotEncoder(), new PrincipalComponentAnalysis(10)]; } // exploratory: the transformed Rubix dataset Ml::dataset()->fromCollection($rows, ['plan', 'spend'])->impute()->oneHot()->normalize('minmax')->toRubix();
Cross validation and reports
Validation::holdout(0.2) (stratified for classifiers), Validation::kFold(5) (stratified, metrics from out-of-fold
predictions) or Validation::none(). Transformers are fitted inside every fold, so no information leaks from the test
data. After validation the final model is fitted on all samples.
| Task | Metrics in TrainingReport::$metrics |
|---|---|
| classifier | accuracy, precision, recall, f1 (macro), mcc; plus confusionMatrix |
| regressor | mae, rmse, r2 |
| clusterer | silhouette, clusters; with a label also v_measure, homogeneity, completeness |
| anomaly detector | anomaly_rate; with a label also accuracy, precision, recall, f1 |
| recommender | items |
Ml::evaluate(ChurnModel::class) scores the active (or a given) version on the current data and returns an
EvaluationReport.
Versions and persistence
storage/app/ml-models/churn-model/v1/model.bin gzip-compressed model
storage/app/ml-models/churn-model/v1/metadata.json features, label, metrics, statistics, hashes, versions
storage/app/ml-models/churn-model/active.json {"version": "v1", "previous": null, ...}
metadata.json records the features and types, the label, the metrics and validation, trained_at, the SHA-256
dataset hash, feature statistics, the estimator class, the package, Rubix and PHP versions, the SHA-256 checksum, the
HMAC signature and the list of serialized classes.
Ml::versions(ChurnModel::class); // list<ModelMetadata>, ->active flags the active one Ml::promote(ChurnModel::class, 'v2'); Ml::rollback(ChurnModel::class); // activates the version before the active one Ml::prune(ChurnModel::class, keep: 3); // never deletes the active version Ml::predict(ChurnModel::class, $user, version: 'v1');
Loaded models are cached per process (long-running workers and Octane reuse them).
Security of model files
Model files are PHP serialized objects, and unserialize() on attacker-controlled data can lead to remote code
execution through magic methods of classes available in the application. Rubix's own RBX format only adds a crc32
checksum, which detects corruption but not tampering. This package therefore:
- verifies the SHA-256 checksum of
model.binagainst the metadata, - verifies an HMAC-SHA256 signature (over the checksum and the class list) made with
ML_SIGNING_KEYorAPP_KEY; unsigned files are refused whileML_REQUIRE_SIGNATURE=true(the default), - refuses any recorded class outside
ml.security.allowed_namespaces(Rubix\ML\,Tensor\,OkBloomer\,Beziworld\Ml\), - calls
unserialize()withallowed_classesrestricted to the recorded classes.
Still: never load model files that come from users, uploads or other applications, keep the model disk private, and
rotate the signing key together with retraining (files signed with an old key are refused). Anyone who can read
APP_KEY and write to the model disk can forge a model.
Laravel extras
php artisan ml:train churn # alias from ml.models or a class name php artisan ml:train "App\Ml\ChurnModel" --folds=10 --no-promote php artisan ml:train churn --queue # TrainModel job on ML_QUEUE php artisan ml:evaluate churn v2 # metrics and confusion matrix on current data php artisan ml:models # all models; ml:models churn lists versions with metrics php artisan ml:promote churn v2 php artisan ml:rollback churn php artisan ml:prune --keep=3 php artisan ml:drift churn # exit code 1 when the current data drifted
Nightly retraining and monitoring in routes/console.php:
use Illuminate\Support\Facades\Schedule; Schedule::command('ml:drift churn')->dailyAt('01:30')->onFailure(fn () => /* notify the team */ null); Schedule::command('ml:train churn --queue')->dailyAt('02:00'); Schedule::command('ml:prune --keep=7')->weekly();
Jobs and events:
Ml::queue(ChurnModel::class, Validation::kFold(5)); // TrainModel job: tries = 1, timeout = ML_TIME_LIMIT
| Event | Payload |
|---|---|
ModelTrained |
TrainingReport $report, ModelMetadata $metadata |
ModelPromoted |
string $model, string $version, ?string $previous (also on rollback) |
PredictionMade |
Prediction $prediction, array $features; only with ML_PREDICTION_EVENTS=true, sampled by ML_PREDICTION_SAMPLE_RATE |
Eloquent integration:
use Beziworld\Ml\Casts\AsPrediction; use Beziworld\Ml\Concerns\HasPredictions; class User extends Model { use HasPredictions; protected function casts(): array { return ['churn_prediction' => AsPrediction::class]; // text / json column } } $user->churn_prediction = $user->predict(ChurnModel::class); $user->save(); $user->churn_prediction->confidence;
Data drift
$report = Ml::drift(ChurnModel::class, User::query()->where('created_at', '>', now()->subMonth())->cursor()); $report->drifted(); // bool $report->driftedFeatures(); // ['monthly_spend'] $report->feature('plan'); // FeatureDrift: meanShift, distributionShift, missingShift
Numeric, boolean and date features drift when the mean moves by more than ML_DRIFT_MEAN_SHIFT (0.5) training
standard deviations; categorical features when the total variation distance of category shares exceeds
ML_DRIFT_DISTRIBUTION_SHIFT (0.2); text when the average length changes by more than 50%; any feature when the
missing value rate changes by more than ML_DRIFT_MISSING_SHIFT (0.1). It is a simple statistical alarm, not a
replacement for monitoring model accuracy on labelled outcomes.
Association rules (Apriori)
$apriori = Ml::apriori(support: 0.02, confidence: 0.4) ->train(Order::with('items')->lazy()->map(fn ($order) => $order->items->pluck('sku'))); $apriori->frequentItemsets(); // Collection of ['items' => [...], 'support' => 0.031] $apriori->rules(); // Collection<AssociationRule>: antecedent, consequent, support, confidence, lift $apriori->predict(['SKU-1', 'SKU-7'], limit: 3); // recommended SKUs for a basket
Coming from php-ml
| php-ml | laravel-ml |
|---|---|
Classification\KNearestNeighbors |
Estimators::classifier('knn') / Rubix\ML\Classifiers\KNearestNeighbors |
Classification\NaiveBayes |
Estimators::classifier('naive_bayes') (categorical) or 'gaussian_nb' |
Classification\SVC |
Rubix\ML\Classifiers\SVC (needs the svm extension) |
Classification\DecisionTree |
Estimators::classifier('tree') (CART) |
Classification\Ensemble\RandomForest / Bagging / AdaBoost |
'random_forest' / 'bagging' / 'adaboost' |
Classification\Linear\LogisticRegression |
'logistic' (binary) or 'softmax' (multiclass) |
Classification\Linear\Adaline, Perceptron, DecisionStump |
Estimators::regressor('adaline'), 'logistic', new ClassificationTree(1) |
Classification\MLPClassifier |
Estimators::neuralNetwork([hidden...]) |
Regression\LeastSquares |
Estimators::regressor('least_squares') / 'ridge' |
Regression\SVR |
Rubix\ML\Regressors\SVR (needs svm) |
Regression\DecisionTreeRegressor |
Estimators::regressor('tree') / 'gradient_boost' |
Clustering\KMeans / DBSCAN / FuzzyCMeans |
Estimators::clusterer('kmeans' / 'dbscan' / 'fuzzy_cmeans'), ClusteringModel |
Association\Apriori |
Ml::apriori() (Beziworld\Ml\Association\Apriori) |
CrossValidation\RandomSplit / StratifiedRandomSplit |
Validation::holdout() (stratified for classifiers), Validation::kFold() |
Metric\Accuracy, ConfusionMatrix, ClassificationReport |
TrainingReport::$metrics, $confusionMatrix; Rubix MulticlassBreakdown |
Metric\Regression |
mae, rmse, r2 in the report |
FeatureExtraction\TokenCountVectorizer + tokenizers |
text features, Preprocessing::withText(ngrams: n) |
FeatureExtraction\TfIdfTransformer |
Preprocessing::withText(tfidf: true) |
FeatureExtraction\StopWords\English/French/Polish |
StopWords::english()/french()/polish(), stopWords: 'polish' |
Preprocessing\Normalizer |
Preprocessing::withNormalization('zscore' / 'minmax' / 'robust' / 'maxabs') |
Preprocessing\Imputer |
Preprocessing::withImputation() (MissingDataImputer) |
Preprocessing\OneHotEncoder, LabelEncoder, NumberConverter |
automatic per feature type |
Preprocessing\LambdaTransformer, ColumnFilter |
Feature::numeric('x', fn ($row) => ...), the feature list itself |
Pipeline, FeatureUnion |
model definition + transformers(), Preprocessing\ColumnTransformer |
DimensionReduction\PCA / KernelPCA / LDA |
Rubix\ML\Transformers\PrincipalComponentAnalysis / LinearDiscriminantAnalysis in transformers() (no kernel PCA) |
FeatureSelection\VarianceThreshold / SelectKBest |
not covered; use tree feature importances (RanksFeatures) or a custom transformer |
Dataset\ArrayDataset / CsvDataset / FilesDataset |
Ml::dataset()->fromArray() / fromCsv() / fromCollection() |
ModelManager::saveToFile() / restoreFromFile() |
automatic versioned storage, Ml::versions(), Ml::promote() |
Math\Distance, Statistic |
Rubix Kernels\Distance\*, Dataset::statistics() |
Performance notes
PHP is fine for the datasets most Laravel applications have: up to a few hundred thousand rows and a few dozen features train in seconds to minutes on a queue worker. Keep in mind:
- the whole dataset lives in memory as PHP arrays (roughly 100 to 200 bytes per value); set
ML_MEMORY_LIMITand narrow the query or cap it with->limit()when you have millions of rows, - train on a queue (
--queue,ML_TIME_LIMIT), never in a web request, - k-fold validation trains k + 1 models; random forests and gradient boosting are the slowest presets,
- install the Tensor extension for linear models and neural networks; KNN prediction cost grows with the data size,
- the silhouette score samples at most 500 points, the drift check keeps the 50 most frequent categories,
- predictions are cheap after the first load (the model is cached per process).
Use Python (scikit-learn, XGBoost, PyTorch) instead when you need GPU training, deep learning on images or audio, gradient boosting on tens of millions of rows, or a data science team's notebooks. A common split is to keep feature extraction and serving in Laravel and train offline.
Errors
| Exception | When |
|---|---|
InvalidConfiguration |
missing or invalid ml.* settings |
InvalidDataset |
wrong value types, missing labels, empty data, too few samples for the validation |
InvalidModelDefinition |
unknown model, estimator/task mismatch, features changed since training, retraining a restored model |
ModelNotFound |
no active version or unknown version |
IntegrityCheckFailed |
checksum, signature or class allowlist verification failed |
All exceptions extend Beziworld\Ml\Exceptions\MlException.
Development
composer install
composer ci # pint --test, phpstan (level 8), pest
Tests use small deterministic synthetic datasets, a seeded random number generator, an in-memory SQLite database and fake disks and queues. They assert metric thresholds, not exact floats.
Author
Created and maintained by Kamil Ćwiertnia, beziworld.eu.
- E-mail: biuro@beziworld.eu
- Issues: GitHub issues
Need an integration? Get in touch through beziworld.eu.
License
MIT. See LICENSE.md. This package builds on Rubix ML (MIT) and is not affiliated with or endorsed by the Rubix ML project or the authors of php-ml.