Laravel 8 from scratch
Notes on the laracasts course of the same name
Table of Contents
Request (URL call from browser)
-> laravel app is loaded
-> registered response is loaded from routes.php
, e.g. Route::get('/', [PizzaController::class, 'index']);
-> Controller delegates SQL queries to eloquent model; Controller is also the place for domain knowledge / business logic
-> view is loaded (e.g. index.blade.php
), receives the data from the controller and displays it
Laravel Sail
is a docker container and command line app with everything needed for developing in laravel.
Install [(other ways described here)] (https://laravel.com/docs/8.x/installation) with curl -s https://laravel.build/example-app | bash
(Linux) in the directory where the project should be located. Docker must be installed for this to work. example app
can be any name, the laravel.build
URL just returns a shell script with the name given in the URL that is passed to bash with the above command.
To run the app and access it under localhost:
cd example-app
./vendor/bin/sail up
upgrade docker-compose if you get an error on sail up
On first start, this will take some time.
Best add an alias alias sail='bash vendor/bin/sail
to the .bashrc
.
Important To use composer and laravel tools, prefix them with sail
so the right php version is assumed, e.g. sail composer require laravel/sanctum
Another way, if php / DB are installed locally, is using the laravel installer using composer.
CSS frameworks are an abomination
Web routes are defined routes/web.php
, views under resources/views
. Routes don't have to use / return a view. Some route examples:
Route::get('/peer', function () {
return view('peer'); // view file = resources/views/peer.blade.php
});
Route::get('/noview', function () {
return "hey hey my my"; // returns just that, no view file necessary
});
Route::get('/givemesomejson', function () {
// this array gets automatically converted to JSON
return ['here' => ['you', 'have', 'some', 'json']];
});
// $title and $post will be availabe in the view
Route::get('post', function () {
return view('post', [
'title' => '1st post!!!',
'post' => 'hello world'
]);
});
The css / js files and folders in resources
are meant to be compiled / bundled, so we ignore these for now and put the css we use directly under public/app.css
and app.js
and include them in the html as we would in any static page.
In the next step, we store the posts as individual html files in resources/posts and use their name as a slug we append to the URL. The commands dd
and ddd
are debug commands built in laravel (dump and die (dd) ...and debug (ddd)).
A slug / parameter can be checked by using ->where(paramName, regex)
instead of checking it in the route function that returns a 404 if it doesn't match.
Route::get('post/{post}', function ($slug) {
$path = __DIR__ . '/../resources/posts/' . $slug . '.html';
if (!file_exists($path)) {
//ddd('file does not exist'); // dump, die and debug
//abort(404);
return redirect('/');
}
$post = file_get_contents($path);
return view('post', [
'post' => $post
]);
})->where('post', '[A-z_\-]+'); // letters, underscores and dashes
Additional predefined whereX
methods are defined such as whereAlpha(variableName)
, which is the same as where('post', '[A-z]+')
.
Output can easily be cached:
$path = __DIR__ . '/../resources/posts/' . $slug . '.html';
$post = cache()->remember("posts.{$slug}", 5, function () use ($path) {
return file_get_contents($path);
});
The first parameter is any unique key, here we're using the extrapolated route (e.g. "post.my-fine-post" will always return the content of the my-dine-post.html file). The second parameter is the duration the result should be cached in seconds, other ways would be e.g. now()->addMinutes(20)
.
The logic for looking up and loading a post can be put into a model (app/Models). The cache()
function etc. work there as well.
Laravel also provides various filesystem path functions like app_path
or ressource_path
(see usage below);
File app/Models/Post.php:
namespace App\Models;
class Post
{
public static function find($slug) {
if(!file_exists($path=resource_path("posts/${slug}.html"))) {
// redirect('/'); // the method shouldn't be responsible for redirecting
throw new ModelNotFoundException();
}
return cache()->remember("posts.{$slug}", 1, function() use ($path) {
return file_get_contents($path);
});
}
}
The ModelNotFoundException doesn't indicate that the Post class isn't found but that a record isn't found (it extends RecordsNotFoundException
, which makes this clearer).
To add a method to get all existing posts, we can use Laravels File facade class (use Illuminate\Support\Facades\File
). As there are several, make sure to select the right one.
File app/Models/Post.php:
public static function all() {
// File::allFiles returns an array of SplFileInfo objects
return array_map(function ($file) {
// we could also just use $file->getContents(), but the find method might do something
// necessary before returning it in the future
return self::find($file->getFilenameWithoutExtension());
}, File::allFiles(resource_path('posts')));
}
File metadata for the posts can be added with the yaml-front-matter package.
After installing this (composer require spatie/yaml-front-matter
or vendor/bin/
sail composer require spatie/yaml-front-matter when using sail) this, we can add metadata that we can read / use to the html files of the individual posts.
File resources/posts/my-first-post.html
:
---
title: 1st post!1!!
excerpt: agga gf dsgfdasfga
date: 2021.12.24
---
<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. A, amet cumque deserunt esse est excepturi molestiae odit sequi ut veniam....</p>
We can then add properties and a constructor to the Post
model:
class Post
{
public $title;
public $excerpt;
public $date;
public $body;
public $slug;
/**
* @param $title
* @param $excerpt
* @param $date
* @param $body
*/
public function __construct($title, $excerpt, $date, $body, $slug)
{
$this->title = $title;
$this->excerpt = $excerpt;
$this->date = $date;
$this->body = $body;
$this->slug = $slug;
}
// ...
In the all
in the Post
model method, we can create and return new Posts. Laravel provides a collection function that provides many methods such as map, each, filter and many more. One of the main advantages is the fluent interface instead of wrapping multiple array_map
s.
public static function all()
{
return collect(File::files(resource_path("posts")))
->map(function ($file) {
return YamlFrontMatter::parseFile($file);
})
->map(function ($yfm) {
return new Post(
$yfm->matter('title'),
$yfm->matter('excerpt'),
$yfm->matter('date'),
$yfm->body(),
$yfm->matter('slug'),
);
});
}
In the find
method we can now use the all()
method to return a post by its slug. The laravel collection class provides methods such as firstWhere
here to select the first item in a collection with a property matching a certain value. This could be done with filter
as well but firstWhere
is more terse.
public static function find($slug)
{
return cache()->remember("posts.{$slug}", 5, function () use ($slug) {
return self::all()->firstWhere('slug', $slug);
});
}
Laravel collections has several sort methods. We can also use the cache to cache a result "forever", meaning until we manually or programmatically update it.
public static function all()
{
return cache()->rememberForever('posts.all', function () {
return collect(File::files(resource_path("posts")))
->map(function ($file) {
return YamlFrontMatter::parseFile($file);
})
->map(function ($yfm) {
return new Post(
$yfm->matter('title'),
$yfm->matter('excerpt'),
$yfm->matter('date'),
$yfm->body(),
$yfm->matter('slug'),
);
})
->sortBy('date', SORT_REGULAR, true);
});
}
To update the cache, we can use the php artisan tinker
(or sail artisan tinker
when using sail) shell using cache()->forget('posts.all')
. This can of course be done in the app / cron job as well.
pk@pk-lightshow:~/projects/php/laravel/laravel8_from_scratch/blog$ sail artisan tinker
Psy Shell v0.10.12 (PHP 8.1.0 — cli) by Justin Hileman
>>> cache('posts.all') # view cache
=> Illuminate\Support\Collection {#3503
all: [
2 => App\Models\Post {#3499
+title: "3rd post!1!!",
# etc...
>>> cache()->forget('posts.all') # clears cache
=> true
Cache items can also be viewed with cache()->get(keyname)
, or set with cache()->put(key, val)
or just cache([key=>val], optionalDurationInSeconds)
.
The Model might not be a good place to read the filesystem, so it might be a good idea to put that into a Serviceprovider (app/Providers
). We will not do that here / now.
While PHP can still be used in the templates, it has also a template language that makes writing views more comfortable. So instead of writing <?php echo $post->title; ?>
we can just write {{ $post->title }}
. The .blade.php
file suffix is required for the blade tags to be parsed.
In storage/framework/views
you can see the compiled pure php versions of the blade views.
By default, the piped variables shown with {{ var }}
are escaped, so contained tags appear as normal text. To show them unescaped, use {!! var !!}
.
Control structures such as foreach
can be used by adding an @
in front ("blade directives").
@foreach($posts as $post)
<article>
<a href="/post/{{ $post->slug }}"> <h1>{{ $post->title }}</h1></a>
<div>{{ $post->excerpt }}</div>
</article>
@endforeach
This translates to
<?php $__currentLoopData = $posts; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $post): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
<article>
<a href="/post/<?php echo e($post->slug); ?>"> <h1><?php echo e($post->title); ?></h1></a>
<div><?php echo e($post->excerpt); ?></div>
</article>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
This means that the created $loop
variable is accessible in blade and can e.g. be shown with @dd($loop)
. This also means that you can't define your own variable named $loop
here.
Example @dd($loop)
output:
{#293 ▼
+"iteration": 1
+"index": 0
+"remaining": 3
+"count": 4
+"first": true
+"last": false
+"odd": true
+"even": false
+"depth": 1
+"parent": null
}
The $loop
properties can be useful for conditional formating in the html by checking for "->last", "->odd" or "->even".
Blade also has convenience flow control keywords such as @unless / @endunless
as the opposite of @if
.
Comments in blade: {{-- my useful comment --}}
Layouts (to avoid repetitive HTML boilerplate code in each view) can be defined in 2 ways.
Create a layout.blade.php
(the name is not important) in the views
directory and add one or many @yield
directives where the content of the other views should go (content
in the example is arbitrary):
layout.blade.php:
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>My awesome blog</title>
<link rel="stylesheet" href="/app.css">
<script src="/app.js"></script>
</head>
<body>
@yield('content')
</body>
</html>
post.blade.php:
@extends('layout')
@section('content')
<article>
<h1>{{ $post->title }}</h1>
{!! $post->body !!}
</article>
<a href="/">Go back</a>
@endsection
Blade components allow to wrap html. To create them, create a components
directory under views
. The name of the directory is NOT arbitrary and must be components
. Once created, the components created in there are immediately available.
Here is a simple example. $slot
is a non-arbitrary name of the default content that is wrapped in <x-viewName></x-viewName>
tags in the views.
views/components/myPrettyLayout.blade.php
<!doctype html>
<html lang="de">
<head>
...
</head>
<body>
{{ $slot }}
</body>
</html>
views/post.blade.php
<x-myPrettyLayout>
<article>
<h1>{{ $post->title }}</h1>
{!! $post->body !!}
</article>
<a href="/">Go back</a>
</x-myPrettyLayout>
Both ways to create layout template approaches are equal.
Components don't have to be just a html scaffold but can also be used for single components such as buttons, similar to react components.
->where(...
) in routes/web.php
as we shouldn't need it anymore the way we find the slugnull
as it doesn't find a post with that name. The view still gets rendered but as $post is null, it produces an error.find
and a findOrFail
method in the model:models/Post.php
public static function find($slug)
{
return static::all()->firstWhere('slug', $slug);
}
public static function findOrFail($slug) {
$post = static::find($slug);
if(! $post) {
throw new ModelNotFoundException();
}
return $post;
}
The exception returns a 404 to the user.
The usual .env
file stuff, accessed with env(settingName, defaultValue)
in laravel. DB settings are set up there.
With sail
, you can log into mysql with sail mysql -u root -p
.
Users are root/(no password) and sail/"password".
The mysql database is also accessible on the host system on the usual port 3306.
From here, I will not prefix any laravel or container related commands with sail
anymore as it is implied if you use sail!
To do an initial database setup for the up, use artisan migrate
that sets up the basic system tables such as users
, password_resets
etc.
These migrations are defined in database/migrations
.
The methods defined in the migrations up
and down
are to apply and reverse the migrations (database setups like table creation) respectively.
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('users');
}
When changing these files, roll back the migrations (php artisan migrate:rollback
) and reapply them.
To drop all tables and redo all migragions, use migrage:fresh
. When setting APP_ENV=production
in .env
, a warning is issued before applying destructive migrations.
Each table can have a corresponding eloquent model. In the active record pattern, an object instance is tied to a single row of the corresponding table.
A user using the default User model can be created on the command line with tinker
:
pk@pk-lightshow:~/projects/php/laravel/laravel8_from_scratch/blog$ sail artisan tinker
Psy Shell v0.10.12 (PHP 8.1.0 — cli) by Justin Hileman
>>> $user = new User;
[!] Aliasing 'User' to 'App\Models\User' for this Tinker session.
=> App\Models\User {#3504}
>>> $user->name = 'pk';
=> "pk"
>>> $user->email = '[email protected]'
=> "[email protected]"
>>> $user->password = bcrypt('test123');
=> "$2y$10$l35.izzm3T7kTaXL5ewVL.JBesWFVJDq0eE/M2o8lqa9FCrxHtX.i"
>>> $user->save();
=> true
Important: when making changes in the model, tinker must be restarted for them to be recognized!
We can also use all the default model / collections methods here.
>>> User::find(2);
=> App\Models\User {#4231
id: 2,
name: "pk",
email: "[email protected]",
email_verified_at: null,
#password: "$2y$10$l35.izzm3T7kTaXL5ewVL.JBesWFVJDq0eE/M2o8lqa9FCrxHtX.i",
#remember_token: null,
created_at: "2021-12-27 15:47:28",
updated_at: "2021-12-27 15:47:28",
}
>>> User::all()->pluck('name'); // only one user so far
=> Illuminate\Support\Collection {#4297
all: [
"pk",
],
}
Deleting the file based model we created, we use artisan
to create a new Migration (table definition) and Model using php artisan make:migration
and ...make:model
respectively.
Side note: to get help on specific artisan commands, prefix them with help
, e.g. php artisan help make:migration
.
pk@pk-lightshow:~/projects/php/laravel/laravel8_from_scratch/blog$ sail artisan make:migration create_posts_table
Created Migration: 2021_12_27_182411_create_posts_table
Artisan guesses from the _table in the migration name that it should create a table and creates a minimal migration file under database/migrations
.
Here we can add the fields we need:
// ...
public function up()
{
Schema::create('posts', function (Blueprint $table) {
$table->id(); // generated by artisan
$table->timestamps(); // generated by artisan
$table->string('slug');
// rest added by us
$table->string('title');
$table->text('excerpt');
$table->text('body');
$table->timestamp('published')->nullable();
});
}
// ...
We can then run migrate to create the actual table in the DB:
pk@pk-lightshow:~/projects/php/laravel/laravel8_from_scratch/blog$ sail artisan migrate
Migrating: 2021_12_27_182411_create_posts_table
Migrated: 2021_12_27_182411_create_posts_table (29.23ms)
Then we can auto-create the Model (The model name should be the singular version of the table name by convention):
pk@pk-lightshow:~/projects/php/laravel/laravel8_from_scratch/blog$ sail artisan make:model Post
Model created successfully.
We can now create a post with tinker the same way we created a user before or we can create a new Post by using mass assignment.
>>> use App\Models\Post;
>>> Post::create(['title' => 'My third post', 'slug' => 'my-third-post', 'excertpt' => 'test123', 'body' => 'It was a long winter night befor solstice']);
Illuminate\Database\Eloquent\MassAssignmentException with message 'Add [title] to fillable property to allow mass assignment on [App\Models\Post].'
Laravel throws an error because we need to explicitly define in the model which fields are allowed to be mass assigned ("mass assigning" meaning to fill all the attributes of an entry in bulk):
# added as property to Post model
protected $fillable = ['title'];
After restarting tinker, we will still get a (confusing) error:
>>> Post::create(['title' => 'My third post', 'slug' => 'my-third-post', 'excertpt' => 'test123', 'body' => 'It was a long winter night befor solstice']);
Illuminate\Database\QueryException with message 'SQLSTATE[HY000]: General error: 1364 Field 'slug' doesn't have a default value (SQL: insert into `posts` (`title`, `updated_at`, `created_at`) values (My third post, 2021-12-27 18:56:41, 2021-12-27 18:56:41))'
This is caused by only title
being assigned as fillable, so the other passed attributes are ignored and Eloquent tries to use the defaults for the missing fields, which for most don't exist / aren't defined in the migration, so we must add all fields that should be mass assignable to the $fillable property.
The opposite to $fillable
is $guarded
which, if set, signals to Eloquent that all properties are fillable EXCEPT the ones defined in the $guarded
array, e.g. $guarded = ['id', 'created']
.
A third option is to disable mass assignment entirely by setting $fillable to an empty array (and simply to never do mass assignment in the code).
In the route, we can simplify the route by indicating a type of Post
in the callback and Laravel will bind the route key to an underlying eloquent model:
Before:
Route::get('post/{id}', function ($id) {
// Find a post by it's id and pass it to a view called post
return view('post', ['post' => Post::findOrFail($id)]);
});
After:
Route::get('post/{post}', function (Post $post) {
return view('post', ['post' => $post]);
});
Important: the variable name in the route MUST match with the parameter name, so this would NOT work:
Route::get('post/{foo}', function (Post $post) {
return view('post', ['post' => $foo]);
});
By default, this expects / uses the ID of the model as the route parameter. We can very simply change that to use any model field, here we use slug:
Route::get('post/{post:slug}', function (Post $post) {
return view('post', ['post' => $post]);
});
If 2 routes are defined, the last one is the one evaluated, so selecting by id will NOT work anymore if we have both routes:
Route::get('post/{post}', function (Post $post) {
return view('post', ['post' => $post]);
});
Route::get('post/{post:slug}', function (Post $post) {
return view('post', ['post' => $post]);
});
If the slug will always be the identifier for a model, we can also leave it as before (using only {post}
instead of {post:slug}
) and instead add a method called getRouteKeyName()
to the model:
public function getRouteKeyName()
{
return 'slug'; // TODO: Change the autogenerated stub
}
For older laravel versions, the latter is the only working approach.
To make a categories model so that we can assign categories to blog posts, we could use
php artisan make:migration create_categories_table
php artisan make:model
but we can do this in one step by adding -m
to make:model (we could also create a controller with -c
and -f
to create a factory):
php artisan make:model Category -m
We can now add our fields to the created migration ('name' and 'slug', where slug is optional) and add a foreign key to the posts migragtion:
Categories migration:
$table->string('name');
$table->string('slug')->unique(); // if name != slug
Posts migration:
$table->foreignId('category_id');
Then run php artisan migrate:refresh
. We can now use tinker to create categories and posts:
>>> $cat = new App\Models\Category
>>> $cat->name="very important"; $cat->slug="important";
>>> $cat->save();
>>> $cat = new App\Models\Category
>>> $cat->name="just for fun"; $cat->slug="fun"; $cat->save();
>>> Post::create(['title' => '3rd post', 'excerpt'=>'adgfds gf asd', 'body'=>'dgf asfsdfa fdsgfdhjg eoruitgh gf ghfghfdlk g', 'slug'=>'my-third-post', 'category_id' => 2]);
We can now change the Model so we can show the category of a given post (the full category entry, not just the foreign category_id). The method name is important and must be the same as the model name, unless we specify a second argument for belongsTo
. See Posts by author and other housekeeping stuff)
Models/Post.php
public function category()
{
return $this->belongsTo(Category::class);
}
Possible relationship methods are: hasOne, hasMany, belongsTo, belongsToMany
More explanation of hasMany vs belongsToMany here.
We can now use this method as a property (not as a method):
>>> $p->category
=> App\Models\Category {#4229
id: 1,
name: "very important",
slug: "important",
created_at: "2021-12-28 10:28:38",
updated_at: "2021-12-28 10:28:38",
}
We can use this in the view, e.g. <a href="#">{{ $post->category->name }}</a>
We can add a route showing all posts belonging to a category by adding a posts
method (name arbitrary but using the plural of the model makes sense) to the category model:
Models/Category
public function posts() {
return $this->hasMany(Post::class);
}
routes/web.php
Route::get('categories/{category:slug}', function(Category $category) {
return view('posts', ['posts' => $category->posts]);
});
Problem: At the moment, we're performing a sql query in every loop iteration as the posts are not automatically hydrated / filled with the category entry of the relationship but only on access.
views/posts.blade.php
@foreach($posts as $post)
...
<a href="/categories/{{$post->category->slug}}">{{ $post->category->name }}</a>
@endforeach
We can log the queries using Illuminates DB facade in the main route:
Route::get('/', function () {
\Illuminate\Support\Facades\DB::listen(function($query) {
//\Illuminate\Support\Facades\Log::info('query executed');
//or, shorter
logger($query->sql, $query->bindings);
});
return view('posts', ['posts' => Post::all()]);
});
When checking storage/logs/laravel.log
we can see that for one page load of /
we have one sql query for each post after the initial (lazy) loading of all posts in the route:
[2021-12-28 12:54:33] local.DEBUG: select * from `posts`
[2021-12-28 12:57:46] local.DEBUG: select * from `categories` where `categories`.`id` = ? limit 1 [1]
[2021-12-28 12:57:46] local.DEBUG: select * from `categories` where `categories`.`id` = ? limit 1 [2]
[2021-12-28 12:57:46] local.DEBUG: select * from `categories` where `categories`.`id` = ? limit 1 [2]
Side note: a good debugging tool is clockwork which gives access to performance and other information under /clockwork
or in as a tab in the browser devtools. Clockwork must be installed with composer in the application and as a browser plugin
To solve the n+1 problem, we load all the categories in the initial loading of posts in the route using with
:
return view('posts', ['posts' => Post::with('category')->get()]);
No we can see (in the log or with clockwork) that only 2 queries are executed, no matter how many posts:
As we're changing and refining the models during development (e.g. adding a foreign key user_id
to the posts migration), the database refresh always drops and recreates all tables so that the current data is lost.
In database/seeders/DatabaseSeeder.php
we can seed the database with initial records. In there is already a (commented out) setup for 10 fake users using database/factories/UserFactory.php
, which uses faker to create fake data. We can run this with artisan db:seed
to create 10 user records (and 10 more on any subsequent run).
We can change DatabaseSeeder.php
for our needs (we've added a foreign user_id key in the posts migration).
database/seeders/DatabaseSeeder.php
public function run()
{
// remove existing data so we don't get an exception when trying to insert
// the same value in a unique column
Category::truncate();
User::truncate();
Post::truncate();
// create one user with the already existing factory
$user = User::factory()->create();
// create 2 categories (we don't have a factory for these yet)
$fun = Category::create([
'name' => 'fun stuff',
'slug' => 'fun'
]);
$serious = Category::create([
...
]);
...
Post::create([
'title' => 'Spray apache pool strength lying visited. ',
'slug' => 'my-first-post',
'body' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
'excerpt' => 'abc 1234 i am an expert in excerpts',
'category_id' => $fun->id,
'user_id' => $user->id
]);
Post::create([
...
]);
...
}
We can then refresh the database and seed it in one go with artisan migrate:fresh --seed
.
Side note: if we want to display the user in the views and avoid, yet again, unnecessary DB queries in each iteration, we must add user
to the with
clause in the route:
Each generated eloquent model comes with use HasFactory
that gives access to a factory function (see User::factory()->create()
). The attributes will be created in the definition
method of the modeos factory located in the database/factories
directory, named ModelnameFactory.php, e.g. UserFactory.php
. The factory method itself doesn't need to be defined by hand.
Example of the ' definition` method in the predefined UserFactory:
public function definition()
{
return [
'name' => $this->faker->name(),
'email' => $this->faker->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
'remember_token' => Str::random(10),
];
}
As long as no factory class exists in database/factories
, an error will occur when trying to use Modelname::factory()->create()
. We can generate a factory with artisan:
php artisan make:factory PostFactory
Alternatively, we can create a factory when creating the model:
php artisan make:model Category -f # or --factory
Side note: we can create everything in one go by using -a
or --all
. See all options in artisan help make:model
.
faker
can be accessed as a property of $this
in the factory class.
After we created the PostFactory, we can fill in the definition
methods body:
return [
'title' => $this->faker->title(),
'excerpt' => $this->faker->sentence(),
'slug' => $this->faker->unique()->slug(3),
'body' => $this->faker->paragraph(),
'published' => $this->faker->dateTimeBetween('-10 days'),
// don't forget to import these, e.g. use App\Models\Category;
// these create a random category / user for each post
'category_id' => Category::factory(), // we created this as well
'user_id' => User::factory(),
];
We can try this out with tinker:
>>> App\Models\Post::factory()->create();
=> App\Models\Post {#3611
title: "Prof.",
excerpt: "Quia sit et consequatur alias id.",
slug: "molestias-ratione-perspiciatis-quis",
body: "Odit tempora alias aliquid nobis aliquam non maiores. Ipsam sunt autem unde quidem dolor laboriosam. Ullam vero voluptatem voluptas sed et provident vel ipsam. Tempora molestias dolores ipsum eos ut sequi.",
category_id: 4,
user_id: 2,
updated_at: "2021-12-28 18:51:46",
created_at: "2021-12-28 18:51:46",
id: 4,
}
No we can remove all the hand-creation in DatabaseSeeder.php
(or leave some in if specific, non-random Posts / users / categories should be created) and just call Post::factory(10)->create();
instead to create 10 posts with users / categories.
If we want to assign specific non-random values, we can do so by passing the key and value to create:
User::factory()->create(['name' => 'John Doe']);
This can be used to make the data generation a little more "realistic":
// create 6 posts in 2 different categories by the same user
$pk = User::factory()->create(['name' => 'PKRO']);
$fun = Category::factory()->create(['name'=>'fun stuff', 'slug' => 'fun']);
$serious = Category::factory()->create(['name'=>'serious stuff', 'slug' => 'serious']);
Post::factory(3)->create(['user_id' => $pk->id, 'category_id' => $fun->id]);
Post::factory(3)->create(['user_id' => $pk->id, 'category_id' => $serious->id]);
// create 5 more of everything, random
Post::factory(5)->create();
We can easily get results for Posts by using latest
; under the hood, eloquent will add an order by
statement.
Post::latest('published')->with(['category', 'user'])->get()
To change the way we refer to semantically refer to a posts author as author and not (just) user, we can add a second argument to belongsTo
:
// we just keep that so code that still refers to ->user doesn't break
public function user()
{
// possible: hasOne, hasMany, belongsTo, belongsToMany
return $this->belongsTo(User::class);
}
// this way we can refer to the autor as Post->author, note the second
// argument in belongsTo
public function author()
{
// possible: hasOne, hasMany, belongsTo, belongsToMany
return $this->belongsTo(User::class, 'user_id');
}
When we add properties this way, we must add them to the with
method in web.php
to avoid lazy loading (multiple sql queries for each post to get the author): ...with(['category', 'user', 'author'])...
, even if author / user refer to the same page.
To add a route to view all posts by author name, we can simply reuse the posts view. As The user's name is not unique (there can be multiple "Paul Smith"), we will add a new field username
in the User migration and seeder factory, then refresh and reseed with sail artisan migrate:fresh --seed
as usual.
routes/web.php
Route::get('authors/{author:username}', function(User $author) {
return view('posts', ['posts' => $author->posts]);
});
In the category and author overview we don't eagerly load author and category yet, leading to an additional query for every displayed post again. We can fix that by adding the load method to the posts property:
return view('posts', ['posts' => $author->posts->load(['category', 'author'])]);
Another way to accomplish this would be to add the models that should always be eagerly loaded to the Post model:
// this would always eagerly load the linked models
//protected $with = ['category', 'author'];
You could then add without
for queries where you don't want eager loading, e.g. Post::without()->first()
.
Just some notes as I'm not planning to use the design and images as it uses tailwind and doesn't have a license file in the repo.
Short notes on design integration:
views/components/post-card.blade.php
)<x-post-card />
(no dynamic data yet) in the posts overview.post-featured-card.blade.php
@include
, e.g. @include('_posts-header')
(**don't add .blade.php
!), which simply translates to php include
when compiled. <x-postCard :post=$post />
; In the course @props(['post'])
is added at the top of the component, but seems to work without (?)explanation by ikartik90 here: I tried this out, and turns out that if you don't provide @props but still pass them to your Blade component, it takes the passed props (say, the $post variable in this case) and appends them as attributes on the first element of the component (the article tag in the above case).
Hence, once rendered, the generated code looks like:<article class="whatever classes passed" post="$post JSON object">
And then it uses the above JSON object passed as attribute to the parent to render the content in the children.
But in doing so, even though it ends up preventing your web app from crashing and burning, everything you've fetched using your $post variable including any eager loaded relationships would get exposed, including their respective record IDs. Hence, I would rather suggest exercising on the side of caution and ensuring that you pass the props diligently.
<x-postCard class="postCard" id="myId">
can be passed into components and accessed using the $attributes
variable in the component, e.g. <article {{ $attributes->merge(['class' => 'anotherclass']) }}>
, which would translate to <article class="anotherClass postCard" id="myId">
$post->created_at->diffForHumans()
$category->id === $currentCategory->id
you can write $category->is($currentCategory)
AlpineJS allows for declarative and reactive javascript functionality similar to vue.js with a 6kb include.
See _header.blade.php
for an example of a javascript pulldown using alpine.js (included from a cdn):
<div class="jsPulldown" x-data="{ show: false }">
<button
@click="show = !show"
@click.away="show = false">
<span>
{{ isset($currentCategory) ? ucwords($currentCategory->name) : 'Category' }}
</span><span>▽</span>
</button>
<!-- setting display to none in the css file causes this to never show - why? -->
<div x-show="show" style="display: none;">
@foreach($categories as $category)
@unless(isset($currentCategory) && $category->is($currentCategory))
<a href="/categories/{{$category->slug}}">{{ucwords($category->name)}}</a>
@endunless
@endforeach
</div>
</div>
Side note: defer
in a script tag means it should be loaded in parallel but executed after the page is loaded.
->name('home')
) and accessed in view (request()->routeIs('home')
). This can be useful for checking which menu item to set to active / style differently.The alpinejs functionality can be further isolated into it's own component:
dropdown.blade.php
<div class="jsPulldown" x-data="{ show: false }">
<div
@click="show = !show"
@click.away="show = false">
{{-- trigger element, which can be anything --}}
{{ $trigger }}
</div>
<!-- setting display to none in the css file causes this to never show - why? -->
<div x-show="show" style="display: none;">
{{-- links --}}
{{ $slot }}
</div>
</div>
_header.blade.php
<x-dropdown>
<x-slot name="trigger">
<button>
<span>
{{ isset($currentCategory) ? ucwords($currentCategory->name) : 'Category' }}
</span>
<span>▽</span>
</button>
</x-slot>
{{-- this goes into the default {{$slot}} --}}
<x-slot name="slot">
@foreach($categories as $category)
@unless(isset($currentCategory) && $category->is($currentCategory))
<a href="/categories/{{$category->slug}}">{{ucwords($category->name)}}</a>
@endunless
@endforeach
</x-slot>
</x-dropdown>
In the main way, we could add a where clause to the query for the fields we want to search for the searchterm entered, which we find in the "search" variable (the search field is a GET form with only the search
field in _header.blade.php
:
routes/web.php
Route::get('/', function () {
$posts = Post::latest('published')->with(['category', 'author']);
if (request('search')) {
$posts
->where('title', 'like', '%' . request('search') . '%')
->orWhere('body', 'like', '%' . request('search') . '%');
}
return view('posts', [
'posts' => $posts->get(),
'categories' => Category::all(),
'currentCategory' => null,
'searchTerm' => request('search')
]);
})->name('home');
views/posts.blase.php
<x-myPrettyLayout :showControls="true" :categories="$categories" :currentCategory="$currentCategory" :searchTerm="$searchTerm">
...
views/partials/_header.blade.php (just to keep the searchterm in the search field after the search)
...
<input type="text" name="search" placeholder="Find something" value="{{$searchTerm}}">
...
We want to extract the logic / search specification from our routes file as it becomes messy with logic, which is not its responsibility, so we create a controller:
php artisan make:controller PostController # name is arbitrary
This creates a PostController.php
file in app/Http/Controllers
In this controller we can create a method index
(name arbitrary but fitting for our purpose / route):
use App\Models\Category;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function index() {
$posts = Post::latest('published')->with(['category', 'author']);
if (request('search')) {
$posts
->where('title', 'like', '%' . request('search') . '%')
->orWhere('body', 'like', '%' . request('search') . '%');
}
return view('posts', [
'posts' => $posts->get(),
'categories' => Category::all(),
'currentCategory' => null,
'searchTerm' => request('search')
]);
}
}
In the routes file we specified the action to be taken for that route with an anonymous function so far, but we can put anything of a callable
type, which can be specified (in PHP in general) using an array with the class name and the method:
web.php
Route::get('/', [PostController::class, 'index'])->name('home');
We can (and should) do the same for all the other routes in web.php
(see code).
We can further refactor the actual query for the title / body into its own protected method in the controller, but a better way would be to add a query scope to the Post
eloquent model:
models/Post.php
public function scopeFilter($query) {
if (request('search')) {
$query
->where('title', 'like', '%' . request('search') . '%')
->orWhere('body', 'like', '%' . request('search') . '%');
}
}
Http/Controllers/PostController.php
public function index() {
//...
return view('posts', [
'posts' => Post::latest('published')->with(['category', 'author'])->filter()->get(),
// ...
Problem: we don't want to access the request parameter directly from the model as this doesn't seem to be the Model's responisibility. Instead, we accept a list of filters in the scopeFilter
method.
public function scopeFilter($query, array $filters) {
if (isset($filters['search'])) {
$query
->where('title', 'like', '%' . request('search') . '%')
->orWhere('body', 'like', '%' . request('search') . '%');
}
}
which is equivalent to
public function scopeFilter($query, array $filters) {
$query->when($filters['search'] ?? false, function($query, $search) {
$query
->where('title', 'like', '%' . $search . '%')
->orWhere('body', 'like', '%' . $search . '%');
});
}
and pass ist an array in the PostController like this:
'posts' => Post::latest('published')->with(['category', 'author'])->filter(request()->only('search'))->get(),
request()->only('search')
just returns an array ['search' => "search value"]