Using it with a framework
CommonKit has no dependency on any framework — its composer.json requires PHP, intl
and mbstring, and nothing else. It was built for the three moments where an application
meets its framework's edges, and on Laravel, Joomla! and WordPress the same calls go in the
same places: reading the request, where CkRequest
fills the bag every CkDictionary reader takes; saving, where
CkSentinel::ToNull turns every placeholder — empty text, a zero,
1900-01-01, year one — into an honest NULL on the way to the database; and storing files,
where CkFile keeps every file inside a FilePlace
the platform already has a folder for. One section per framework: how the library comes in,
and where each call goes.
The framework plumbing below lives in your application, extension or plugin; the library only supplies the value logic. The blocks that reach into a framework are fragments — they show where the call goes, not a working file.
Laravel
Getting it in
Composer, which is Laravel's autoloader too: once the package is required, every class is
there from the next request on, with nothing to register and no service provider. Today the
library travels as source, so it comes in as a path repository on the php/ folder of the
repository — one entry in your application's composer.json:
{
"repositories": [
{ "type": "path", "url": "../CommonKit/php" }
]
}
then, from the application's folder:
composer require safecoder/commonkit:@dev
Once it is on Packagist the first block goes away and the second loses its @dev:
composer require safecoder/commonkit. Getting started shows the
folder layout the path points at.
Reading the request
Laravel has read the request before your code runs, but the raw body stays readable, so
CkRequest reads the same in a controller or a form request as in plain PHP: on first use it
reads what the current request carries straight from what PHP was handed, never from
$_REQUEST, picks the reader by the request's method and content type, and every call hands
out a typed value. On GET and HEAD its own part is the raw query string — never $_GET, whose keys PHP
has already rewritten; on any other method a JSON body (application/json, or any …+json)
or a form body (application/x-www-form-urlencoded), PUT and PATCH included; a
multipart/form-data body is the one PHP has already consumed, so on POST it is $_POST,
keys as PHP rewrote them. In a controller nothing is set up, and each field comes out through
the reader that owns its kind:
// no-check
use Illuminate\Http\Request;
use SafeCoder\CommonKit\CkDictionary;
use SafeCoder\CommonKit\CkEmail;
use SafeCoder\CommonKit\CkRequest;
Route::post('/signup', function (Request $request) {
$email = CkEmail::Address(CkRequest::String('email')); // a JSON body or a form, whichever the client sent
$newsletter = CkRequest::Bool('newsletter');
$page = CkDictionary::Integer(dictionary: CkRequest::Bag()->Query(), key: 'page', fallback: 1); // the address, on a POST
// …
});
A test sets the request as the web server would — the method, the content type, the query string, the fields of a form — and the first call reads it, which is also the clearest way to show what the readers do:
use SafeCoder\CommonKit\CkDictionary;
use SafeCoder\CommonKit\CkRequest;
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['QUERY_STRING'] = 'page=2&active=yes';
CkRequest::Bag()->Query(); // -> ['page' => '2', 'active' => 'yes'] (a GET: the raw query string)
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['CONTENT_TYPE'] = 'multipart/form-data';
$_SERVER['QUERY_STRING'] = 'page=2';
$_POST = ['id' => '7', 'email' => 'ANA@EXAMPLE.COM'];
CkRequest::Bag()->Body(); // -> ['id' => '7', 'email' => 'ANA@EXAMPLE.COM'] (a POST: the form, its own part)
CkRequest::Number('id')->Integer(); // -> 7
CkDictionary::Integer(CkRequest::Bag()->Query(), 'page'); // -> 2 (the address, by name)
$_SERVER['CONTENT_TYPE'] = 'text/plain';
CkRequest::Explain(); // -> 'a text/plain body is not one the reader knows'
The bag is raw on purpose — '2' is text, 'ANA@EXAMPLE.COM' is upper case — because
cleaning happens at each read, where the kind of the field is known. Where you want the bag
itself, for a validator or a saving hook, CkRequest::Bag()->Body() is it, and
CkPayload::FromJson(), with nothing passed, reads the request's
body straight into one. Where you would rather go through the request object Laravel built,
$request->getContent() is the raw body — the same text CkRequest reads — and
FromJson takes it; and $request->all(), the array Laravel
decoded from the body or the form, is a bag already, so it feeds CkDictionary as it is:
// no-check
$payload = CkPayload::FromJson($request->getContent()); // the body as received
A body whose root is a JSON array is a batch: $request->getContent() hands it over as
received, for FromJsonMaps — and for a webhook signature
computed over the bytes as sent, which is the request object's job, never the library's.
A Carbon that CkDate::ToCarbon hands you is the library's own
copy of the class, not Laravel's: anything typed DateTimeInterface takes it, and
Carbon::instance($moment) makes it Laravel's in one call.
Validating
The validator of Writing a validator — one static method that takes
the bag and answers every field's reason at once, empty when the payload is good — goes in a
form request's withValidator, where Laravel folds the reasons into the response it already
knows how to send: a redirect back with the errors for a form, a 422 with them as JSON for an
API client. A controller can call it the same way before the work starts.
// no-check
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
use SafeCoder\CommonKit\CkRequest;
final class SignupRequest extends FormRequest
{
public function rules(): array
{
return []; // the library judges the values, below
}
public function withValidator(Validator $validator): void
{
$validator->after(static function (Validator $validator): void {
foreach (SignupValidator::Validate(CkRequest::Bag()->Body()) as $field => $reason) {
$validator->errors()->add($field, $reason);
}
});
}
}
The guide builds the class; nothing about it changes here.
Saving honest NULLs
Eloquent's saving event fires once for every insert and update, which makes it the one place
to turn placeholders into NULL. ToNull looks at any value and gives one verdict: a
placeholder in, null out; a real value in, the same value out, untouched:
use SafeCoder\CommonKit\CkSentinel;
CkSentinel::ToNull(' '); // -> null
CkSentinel::ToNull(0); // -> null
CkSentinel::ToNull(new DateTimeImmutable('1900-01-01')); // -> null (a placeholder date)
CkSentinel::ToNull('Ana'); // -> 'Ana'
Name the columns the table lets be NULL — keys and NOT NULL columns keep their values:
// no-check
use Illuminate\Database\Eloquent\Model;
use SafeCoder\CommonKit\CkSentinel;
final class Member extends Model
{
/** the columns the table lets be NULL */
private const Nullable = ['referrer', 'phone', 'note', 'terminated_at'];
protected static function booted(): void
{
static::saving(static function (Member $member): void {
foreach (self::Nullable as $column) {
$member->setAttribute($column, CkSentinel::ToNull($member->getAttribute($column)));
}
});
}
}
The same call fits a custom attribute cast's set(), one column at a time, when you would
rather declare it beside the column than list the columns in one place:
// no-check
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use SafeCoder\CommonKit\CkSentinel;
final class NullableCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
{
return $value;
}
public function set(Model $model, string $key, mixed $value, array $attributes): mixed
{
return CkSentinel::ToNull($value); // '' becomes NULL on the way to the row
}
}
and 'referrer' => NullableCast::class among the model's casts. What ToNull turns into
null, and the zeros flag, are on the CkSentinel page.
From then on a query for "missing" asks for NULL and '' both —
whereNull('referrer')->orWhere('referrer', '') — because the table can hold rows saved before
the hook existed; the query trap is on Common mistakes.
Storing files
Where the files go is one line in your copy of CkSettings.php:
// no-check
public const FilePlatform = FilePlatform::Laravel;
From then on every FilePlace is a folder under storage/app, found
through storage_path(): what is served under storage/app/public — images/, media/,
videos/, audio/, the folder the storage:link command publishes — and the rest under
storage/app itself: documents/, uploads/, temporary/, imports/, exports/,
archives/. Nothing else to configure; the folders are made the first time something is
written there. An upload comes out of the request and goes through the place's rules — the
extension one the place takes and never a blocked one, the size within the limit, the content
proving the extension — before a byte is written:
// no-check
use Illuminate\Http\Request;
use SafeCoder\CommonKit\CkFile;
use SafeCoder\CommonKit\Enums\FilePlace;
use SafeCoder\CommonKit\Exceptions\CkFileException;
Route::post('/claims/{claimId}/documents', function (Request $request, int $claimId) {
$upload = $request->file('scan');
$bytes = $upload === null ? '' : (string) file_get_contents($upload->getRealPath());
$path = 'claim-' . $claimId . '/' . CkFile::UniqueName($upload?->getClientOriginalName());
try {
CkFile::Write(FilePlace::Documents, $path, $bytes, throwIfInvalid: true);
} catch (CkFileException $refused) {
return response()->json(['error' => $refused->Reason], 400);
}
return response()->json(['path' => $path]);
});
Serving it back is response()->file() over FullPath, which
never loads the file. A place that must live elsewhere — archives on a mounted volume — keeps
the platform for the rest and says so in .env: CommonKit__Files__Archives__Root=/mnt/archive.
The whole of it, the rules and the walk-through, is on
Setting up your file places.
Joomla!
Getting it in
The library ships as an installable folder: grab it from the repository's releases page and
install it the way Joomla! installs any extension. From then on the classes autoload, and
use SafeCoder\CommonKit\… works in a component, a module or a plugin alike — nothing to
require, nothing to register.
Reading the input
The application's input object has read the request. A form arrives under jform, and asked
for with the array filter it is the bag as sent, values untouched, which is what the readers
want — any other filter would already have changed them:
// no-check
use Joomla\CMS\Factory;
use SafeCoder\CommonKit\CkDictionary;
use SafeCoder\CommonKit\CkEmail;
$input = Factory::getApplication()->getInput();
$payload = $input->get('jform', [], 'array');
$email = CkEmail::Address(CkDictionary::String(
dictionary: $payload,
key: 'email'
));
$newsletter = CkDictionary::Bool(
dictionary: $payload,
key: 'newsletter'
);
// …
The bag is raw on purpose — the values are text, as the browser sent them — because cleaning happens at each read, where the kind of the field is known.
An API request with a JSON body is read the same on Joomla! as anywhere:
CkRequest reads the current request itself on first use, straight from
what PHP was handed and never from $_REQUEST, picking the reader by the request's method and
content type — a JSON body on any method, a form body on POST, PUT and PATCH, the raw query
string on GET — and hands out typed values, the way $input->getInt('id') does, with every
value judged by the reader that owns its kind. In a controller nothing is set up; a test sets
the request as the web server would, which is also the clearest way to show what it does:
use SafeCoder\CommonKit\CkDictionary;
use SafeCoder\CommonKit\CkRequest;
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['CONTENT_TYPE'] = 'multipart/form-data';
$_SERVER['QUERY_STRING'] = 'option=com_shop&task=order.save';
$_POST = ['id' => '7', 'email' => 'ANA@EXAMPLE.COM'];
CkRequest::Number('id')->Integer(); // -> 7 (the form, a POST's own part)
CkDictionary::String(CkRequest::Bag()->Query(), 'task'); // -> 'order.save' (the address, which every Joomla! request carries)
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['QUERY_STRING'] = 'page=2&active=yes';
CkRequest::Bag()->Query(); // -> ['page' => '2', 'active' => 'yes'] (a GET: the raw query string, never $_GET)
Where the bag itself is wanted, CkRequest::Bag()->Body() is it, and
CkPayload::FromJson(), with nothing passed, reads the request's
body straight into one — or takes $input->json->getRaw(), the body the input object already
holds.
Validating
The validator of Writing a validator — one static method that takes
the bag and answers every field's reason at once, empty when the payload is good — belongs
where the form is judged: a table's check(), or the model before it hands the data on. Each
reason goes to the application's message queue, which the form shows on the way back:
// no-check
use Joomla\CMS\Factory;
$app = Factory::getApplication();
$payload = $app->getInput()->get('jform', [], 'array');
$problems = SignupValidator::Validate($payload);
foreach ($problems as $field => $reason) {
$app->enqueueMessage($field . ': ' . $reason, 'error');
}
if ($problems !== []) {
// back to the form, the data kept
}
The guide builds the class; nothing about it changes here.
Saving honest NULLs
store() is the seam to the database, so the values go through ToNull just before it.
ToNull looks at any value and gives one verdict: a placeholder in, null out; a real value
in, the same value out, untouched:
use SafeCoder\CommonKit\CkSentinel;
CkSentinel::ToNull(''); // -> null
CkSentinel::ToNull('0.00'); // -> null (a zero amount, written as text)
CkSentinel::ToNull(new DateTimeImmutable('1900-01-01')); // -> null (a placeholder date)
CkSentinel::ToNull('Ana'); // -> 'Ana'
One thing to know about store(): it skips NULL values by default on an update, keeping
whatever the row holds, so the override passes true. Name the columns the table lets be
NULL — keys and NOT NULL columns keep their values:
// no-check
use Joomla\CMS\Table\Table;
use SafeCoder\CommonKit\CkSentinel;
final class MemberTable extends Table
{
public function store($updateNulls = true)
{
foreach (['referrer', 'phone', 'note', 'terminated_at'] as $column) {
$this->$column = CkSentinel::ToNull($this->$column);
}
return parent::store(true); // updateNulls: a NULL is written, never skipped
}
}
What ToNull turns into null, and the zeros flag, are on the
CkSentinel page.
From then on a query for "missing" asks for NULL and '' both, because the table can hold
rows saved before the override existed; the query trap is on
Common mistakes.
Storing files
Where the files go is one line in your copy of CkSettings.php:
// no-check
public const FilePlatform = FilePlatform::Joomla;
From then on every FilePlace is a folder of the site, found through
JPATH_ROOT: pictures, video and audio under images/, the media manager's folder —
Images and Media are that folder itself, Videos and Audio the subfolders videos/
and audio/ of it, so what an extension stores is what the media manager shows — scratch in
tmp/, and documents/, uploads/, imports/, exports/ and archives/ each a folder of
its own at the site root, made the first time something is written there. An upload comes out
of the input object's files and goes through the place's rules — the extension one the
place takes and never a blocked one, the size within the limit, the content proving the
extension — before a byte is written:
// no-check
use Joomla\CMS\Factory;
use SafeCoder\CommonKit\CkFile;
use SafeCoder\CommonKit\Enums\FilePlace;
use SafeCoder\CommonKit\Exceptions\CkFileException;
$app = Factory::getApplication();
$upload = $app->getInput()->files->get('jform', [], 'array')['scan'] ?? null; // ['name' => …, 'tmp_name' => …, …]
$bytes = is_array($upload) ? (string) file_get_contents($upload['tmp_name']) : '';
$path = 'claim-' . $claimId . '/' . CkFile::UniqueName($upload['name'] ?? null);
try {
CkFile::Write(FilePlace::Documents, $path, $bytes, throwIfInvalid: true);
} catch (CkFileException $refused) {
$app->enqueueMessage($refused->Reason, 'error');
}
Serving a file back is readfile() over FullPath, with the
type MediaType names in the header. A place that must live
elsewhere keeps the platform for the rest and names its folder in the variable
CommonKit__Files__Archives__Root, or in CkSettings::FileRoot(). The whole of it, the rules
and the walk-through, is on Setting up your file places.
WordPress
Getting it in
The library ships as an installable folder: grab it from the repository's releases page, put
it where WordPress keeps its plugins and activate it. From then on the classes autoload, and
use SafeCoder\CommonKit\… works in your plugin or your theme — nothing to require, nothing
to register.
Reading the request
WordPress hands a plugin the superglobals and nothing else — and puts a backslash before every
quote in them, which wp_unslash() exists to undo. CkSettings::RequestUnslash, set true in
the plugin's copy of the library, says so once: CkRequest — and every CkPayload reader
reading PHP's input — then reads the current request
straight from what PHP was handed, never from $_REQUEST, strips that one level of backslashes
from the query string and the multipart fields — the raw body, which WordPress never touches, is
read as it is — and every reader hands out a typed value.
The reader is picked by the request's method and content type: on GET and HEAD the raw query
string — never $_GET, whose keys PHP has already rewritten; on any other method a JSON body
or a form body, PUT and PATCH included; a multipart/form-data body — a form with a file in
it — is the one PHP has already consumed, so on POST it is $_POST. In a handler each field
comes out through the reader that owns its kind, and nothing is set up:
// no-check
use SafeCoder\CommonKit\CkEmail;
use SafeCoder\CommonKit\CkRequest;
add_action('admin_post_signup', static function (): void {
$email = CkEmail::Address(CkRequest::String('email')); // the form, as the browser sent it
$newsletter = CkRequest::Bool('newsletter');
// …
});
The bag is raw on purpose — the values are text, as the browser sent them — because cleaning
happens at each read, where the kind of the field is known; CkRequest::Bag()->Body() is that
bag, for a validator, and CkPayload::FromJson(), with nothing
passed, reads the request's body straight into one.
A REST route's callback is handed a WP_REST_Request, whose get_body() is the raw body for
FromJson. A webhook wants the same body twice: once as the
bytes that were sent, for the signature, and once as a bag. The bytes are the request object's
to hand over — WordPress never touches them, so there is nothing to unslash — and the library
never hands bytes out; the readers read the same text:
// no-check
use SafeCoder\CommonKit\CkDictionary;
use SafeCoder\CommonKit\CkRequest;
add_action('rest_api_init', static function (): void {
register_rest_route('shop/v1', '/webhook', [
'methods' => 'POST',
'permission_callback' => '__return_true', // the signature is the gate
'callback' => static function (WP_REST_Request $request) {
$sent = CkDictionary::String(CkRequest::Bag()->Headers(), 'x-signature'); // the header, by lowercase name
if (hash_equals(hash_hmac('sha256', $request->get_body(), WEBHOOK_SECRET), $sent) === false) {
return new WP_Error('bad_signature', 'bad signature', ['status' => 401]);
}
$id = CkRequest::Number('id')->Integer(); // the same body, read once, as a bag
// …
},
]);
});
Validating
The validator of Writing a validator — one static method that takes
the bag and answers every field's reason at once, empty when the payload is good — goes at the
top of the handler, before anything is written. For a form submitted by fetch() the array is
the whole answer; a classic form redirects back with the problems instead:
// no-check
use SafeCoder\CommonKit\CkRequest;
add_action('admin_post_signup', static function (): void {
$payload = CkRequest::Bag()->Body();
$problems = SignupValidator::Validate($payload);
if ($problems !== []) {
wp_send_json_error($problems, 400); // every reason at once, as JSON
}
// …
});
The guide builds the class; nothing about it changes here.
Saving honest NULLs
$wpdb->insert() and $wpdb->update() take an array of column to value and write a PHP
null as SQL NULL — so the values go through ToNull on the way in. ToNull looks at any
value and gives one verdict: a placeholder in, null out; a real value in, the same value
out, untouched:
use SafeCoder\CommonKit\CkSentinel;
CkSentinel::ToNull(' '); // -> null
CkSentinel::ToNull(0); // -> null
CkSentinel::ToNull(new DateTimeImmutable('1900-01-01')); // -> null (a placeholder date)
CkSentinel::ToNull('Ana'); // -> 'Ana'
CkSentinel::ToNull(false); // -> false (a boolean has no placeholder)
Name the columns the table lets be NULL — keys and NOT NULL columns keep their values:
// no-check
use SafeCoder\CommonKit\CkSentinel;
global $wpdb;
$wpdb->insert(
$wpdb->prefix . 'members',
[
'email' => $email,
'referrer' => CkSentinel::ToNull($referrer), // '' becomes NULL
'phone' => CkSentinel::ToNull($phone),
]
);
$wpdb->update(
$wpdb->prefix . 'members',
['phone' => CkSentinel::ToNull($phone)],
['id' => $id]
);
What ToNull turns into null, and the zeros flag, are on the
CkSentinel page.
From then on a query for "missing" asks for NULL and '' both, because the table can hold
rows saved before the call was there; the query trap is on
Common mistakes.
Storing files
Where the files go is one line in your copy of CkSettings.php:
// no-check
public const FilePlatform = FilePlatform::WordPress;
From then on every FilePlace is a lowercase folder of its own under the
uploads folder, found through wp_upload_dir() — uploads/images/, uploads/documents/,
uploads/exports/ — beside the media library's own year folders and never inside them, made
the first time something is written there. An upload comes out of $_FILES and goes through
the place's rules — the extension one the place takes and never a blocked one, the size
within the limit, the content proving the extension — before a byte is written:
// no-check
use SafeCoder\CommonKit\CkFile;
use SafeCoder\CommonKit\Enums\FilePlace;
use SafeCoder\CommonKit\Exceptions\CkFileException;
add_action('admin_post_claim_document', static function (): void {
$upload = $_FILES['scan'] ?? null; // ['name' => …, 'tmp_name' => …, …]
$bytes = is_array($upload) ? (string) file_get_contents($upload['tmp_name']) : '';
$path = 'claim-' . absint($_POST['claim_id'] ?? 0) . '/' . CkFile::UniqueName($upload['name'] ?? null);
try {
CkFile::Write(FilePlace::Documents, $path, $bytes, throwIfInvalid: true);
} catch (CkFileException $refused) {
wp_send_json_error(['error' => $refused->Reason], 400);
}
wp_send_json_success(['path' => $path]);
});
The uploads folder is served by the web server, so a place that must stay private —
Documents, Imports — is better given a folder of its own above the document root, in the
variable CommonKit__Files__Documents__Root or in CkSettings::FileRoot(), and served through
readfile() over FullPath; the platform keeps the rest. The
whole of it, the rules and the walk-through, is on
Setting up your file places.
Where next
- Writing a validator — every problem with a payload, reported at once.
- Setting up your file places — the platform's folders for every kind of file, or one root of your own, and the rules each place keeps.
- Your first payload — a fifteen-minute tutorial: a messy order payload to clean, typed values, and the whole handler at the end.
- CkSentinel — what
ToNullturns intonull, and the zeros flag.