Backend Development

Build a Simple Job Board APP Using Laravel and Livewire

Build a Simple Job Board APP Using Laravel and Livewire

In this tutorial we will build a simple job board application using Laravel and Livewire. We will also use tailwindcss as the application UI.

 

 

 

Requirements

  • Laravel 12
  • Livewire starter kit
  • Tailwindcss
  • Preline UI

 

Getting Started

For the purpose of this tutorial we will be using laravel 12 and livewire 3. So download the laravel livewire starter kit from github using this link.

After downloading extract the package into some location in your server, for example rename the project folder to be “laravel-job-board-livewire”.

Next cd into the project from terminal:

cd job-board-laravel-livewire

We need to install laravel packages using composer install command:

composer install

After installing modify the .env file. You may not find the .env file, so you can copy the .env file if you have one of your previous projects, or here is a sample .env file:

 .env

APP_NAME="Job Board App"
APP_ENV=local
APP_KEY=base64:WhCsmrZ2QzfQN2cSWz2XvsFl4dn5mJ9N7EhZxDzkA1E=
APP_DEBUG=true
APP_URL=http://localhost

LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=job_board_laravel_livewire
DB_USERNAME=root
DB_PASSWORD=

BROADCAST_DRIVER=log
CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
SESSION_LIFETIME=120

MEMCACHED_HOST=127.0.0.1

REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"

AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false

PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1

VITE_APP_NAME="${APP_NAME}"
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"

Generate the application key with: 

php artisan key:generate

 

Installing npm dependencies

For laravel 12 projects, ensure that you have the last node and npm version so that you can build the npm deps without any errors, in my case i have node v 22.16. 

npm install

Install the preline ui package:

npm i preline

Next load preline css and js in app.css and app.js files.

Open resources/css/app.css and add these lines:

@import "../../node_modules/preline/variants.css";
@source "../../node_modules/preline/dist/*.js";

after @import "tailwindcss"

Also open resources/js/app.js and load the preline js:

import 'preline'

compile the dependencies using npm run dev or npm run build commands.

After this command runs successfully, open another terminal window and launch the project using php artisan serve.

The application will be running on port :8000 which you can visit on the browser, and you will see that the app comes with a ready login and register pages like this:

app-login

 

DB Tables and Migrations

Our job board app requires the user to submit jobs through a form to be viewed by candidates, so we need a table to store the jobs:

php artisan make:model CandidateJob -m

This command creates the migration and CandidateJob model. I selected the name “candidate_jobs” for the table not jobs so as not conflict with built laravel jobs table.

Open the migration file and update with these fields:

public function up(): void
    {
        Schema::create('candidate_jobs', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->string('title');
            $table->string('company');
            $table->string('location');
            $table->text('description');
            $table->string('salary')->nullable();
            $table->timestamps();
        });
    }

Next open the CandidateJob model and add the $fillables and user() relation:

app/Models/CandidateJob.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Factories\HasFactory;

class CandidateJob extends Model
{
    use HasFactory;
   
    protected $fillable = [
        'user_id',
        'title',
        'company',
        'location',
        'description',
        'salary',
    ];

    public function user() : BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

Update the app/Models/User.php model add the relation of candidateJobs():

<?php

namespace App\Models;


use Illuminate\Database\Eloquent\Relations\HasMany;
...

class User extends Authenticatable
{
    ....
    ....

    public function candidateJobs() : HasMany
    {
        return $this->hasMany(CandidateJob::class);
    }
}

 

Now migrate the database to create the tables:

php artisan migrate

You will be asked to create the database with name “<db name>” in the .env file, then laravel will create the db tables. 

Navigate to the /register screen and create a new account, after that you will be redirected to the dashboard page.

Dashboard

In the dashboard page the user can:

  • Manage his account.
  • Post and list the jobs.
  • View and remove the jobs.

Let’s remove the bottom links in the sidebar which are the “repository” and “documentation” links as in the figure:

remove-links

You can do this from resources/views/components/layouts/app/sidebar.blade.php and search for repository and documentation and remove the wrapping <flux:navlist />

Also rename the application title, in resources/views/components/app-logo.blade.php and change “laravel-starter-kit” to “job board app”:

<div class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
    <x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
</div>
<div class="ms-1 grid flex-1 text-start text-sm">
    <span class="mb-0.5 truncate leading-tight font-semibold">Job Board App</span>
</div>

 

Jobs Seeder/Factory

Let’s create a seeder for the jobs table to create some dummy data to be previewed in the home page:

Create jobs factory:

php artisan make:factory CandidateJobFactory --model=CandidateJob

Open the CandidateJobFactory and update with this code:

<?php

namespace Database\Factories;

use App\Models\CandidateJob;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;


class CandidateJobFactory extends Factory
{
    protected $model = CandidateJob::class;

    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'user_id' => User::factory(),
            'title' => $this->faker->jobTitle,
            'company' => $this->faker->company,
            'location' => $this->faker->city,
            'description' => $this->faker->paragraph(5),
            'salary' => $this->faker->numberBetween(40000, 150000)
        ];
    }
}

Next create a seeder:

php artisan make:seeder CandidateJobSeeder

Open the CandidateJobSeeder and update the run() method:

public function run(): void
    {
        App\Models\CandidateJob::factory()->count(100)->create();
    }

Now run the seeder:

php artisan db:seed --class=CandidateJobSeeder

 

Livewire Components

Let’s create the livewire components for the job listing and create job form. Using make:livewire artisan command:

php artisan make:livewire jobs.list-jobs

php artisan make:livewire jobs.job-form

This will create the Jobs/ directory in the Livewire/ directory with two livewire components inside.

Next add the routes for these components, we will utilize livewire full page components where each component is represented by a route.

routes/web.php 

use App\Livewire\Jobs\JobForm;
use App\Livewire\Jobs\ListJobs;

....
....

Route::middleware(['auth'])->prefix('dashboard')->group(function () {
    Route::prefix('jobs')->group(function () {
        Route::get('/', ListJobs::class)->name('jobs.list');
        Route::get('/form/{id?}', JobForm::class)->name('jobs.form');
    });
});

To access these pages add a link the sidebar in the dashboard page. Open resources/views/components/layouts/app/sidebar.blade.php

After the dashboard link:

<flux:navlist.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>

Add these links:

<flux:navlist.item icon="megaphone" :href="route('jobs.list')" :current="request()->routeIs('jobs.list')" wire:navigate>{{ __('Job Listing') }}</flux:navlist.item>
<flux:navlist.item icon="plus" :href="route('jobs.form')" :current="request()->routeIs('jobs.form')" wire:navigate>{{ __('Create Job') }}</flux:navlist.item>

 

Jobs Listing

Let’s work on the job listing livewire component. Open the component class Jobs/ListJobs.php and add this code:

<?php

namespace App\Livewire\Jobs;

use App\Models\CandidateJob;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use Livewire\WithPagination;

class ListJobs extends Component
{
    use WithPagination;

    public $search = '';

    public function updatingSearch()
    {
        $this->resetPage();
    }

    public function destroy($id)
    {
        CandidateJob::find($id)->delete();

        session()->flash('success', 'Job deleted successfully.');

        $this->redirectRoute('jobs.list');
    }

    public function render()
    {
        $jobs = Auth::user()->candidateJobs();

        if($this->search) {
            $jobs->where('title', 'like', "%{$this->search}%");
        }

        $jobs = $jobs->latest()
            ->paginate(10);

        return view('livewire.jobs.list-jobs', ['jobs' => $jobs]);
    }
}

I imported the usePagination trait so that we can use pagination links while displaying the jobs. I declared a public $search propery to search for jobs when the user types in the search field. 

The destroy($id) method triggered when the user clicks the delete button on any job record. After deleting the job we show a session message and redirect again to the job listing page.

In the render() method we retrieved the list of jobs using the current auth user candidateJobs() relation. I checked if the $search property is not empty and then filter by job title, next the $jobs is sent to the component view.

Open the view file in resources/views/livewire/jobs/list-jobs.blade.php

<div class="p-6 bg-white rounded shadow">
    <div class="flex justify-between items-center mb-4">
        <h1 class="text-2xl font-semibold">Job Listings</h1>
        <a href="{{ route('jobs.form') }}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
                <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
            </svg>
            Create Job
        </a>
    </div>

    @if(session('success'))
        <div class="mt-2 bg-teal-100 border border-teal-200 text-sm text-teal-800 rounded-lg p-4 dark:bg-teal-800/10 dark:border-teal-900 dark:text-teal-500" role="alert" tabindex="-1" aria-labelledby="hs-soft-color-success-label">
            <span id="hs-soft-color-success-label" class="font-bold">Success</span> {{ session('success')  }}
        </div>
    @endif

    <div class="mb-4">
        <input type="text" wire:model.live.debounce.300ms="search" placeholder="Search jobs..." class="py-2.5 sm:py-3 px-4 block w-full border-gray-200 rounded-lg sm:text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600">
    </div>

    <div class="flex flex-col">
        <div class="-m-1.5 overflow-x-auto">
            <div class="p-1.5 min-w-full inline-block align-middle">
                <div class="overflow-hidden">
                    <table class="min-w-full divide-y divide-gray-200 dark:divide-neutral-700">
                        <thead>
                        <tr>
                            <th scope="col" class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-neutral-500">Title</th>
                            <th scope="col" class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-neutral-500">Company</th>
                            <th scope="col" class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-neutral-500">Location</th>
                            <th scope="col" class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-neutral-500">Salary</th>
                            <th scope="col" class="px-6 py-3 text-end text-xs font-medium text-gray-500 uppercase dark:text-neutral-500">Action</th>
                        </tr>
                        </thead>
                        <tbody>
                            @forelse($jobs as $job)
                                <tr class="odd:bg-white even:bg-gray-100 hover:bg-gray-100 dark:odd:bg-neutral-800 dark:even:bg-neutral-700 dark:hover:bg-neutral-700">
                                    <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-neutral-200">
                                        <a href="{{route('jobs.form', ['id' => $job->id])}}">
                                            {{$job->title}}
                                        </a>
                                    </td>
                                    <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-neutral-200">{{$job->company}}</td>
                                    <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-neutral-200">{{$job->location}}</td>
                                    <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-neutral-200">{{$job->salary}}</td>
                                    <td class="px-6 py-4 whitespace-nowrap text-end text-sm font-medium">
                                        <a href="{{route('jobs.form', ['id' => $job->id])}}" class="inline-flex items-center gap-x-2 text-sm font-semibold rounded-lg border border-transparent text-blue-600 hover:text-blue-800 focus:outline-hidden focus:text-bblue-800 disabled:opacity-50 disabled:pointer-events-none dark:text-blue-500 dark:hover:text-blue-400 dark:focus:text-blue-400">
                                            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
                                                <path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
                                            </svg>
                                        </a>

                                        <button type="button" wire:click="destroy({{$job->id}})" wire:confirm="Are you sure?" class="cursor-pointer inline-flex items-center gap-x-2 text-sm font-semibold rounded-lg border border-transparent text-red-600 hover:text-red-800 focus:outline-hidden focus:text-red-800 disabled:opacity-50 disabled:pointer-events-none dark:text-red-500 dark:hover:text-red-400 dark:focus:text-red-400">
                                            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
                                                <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
                                            </svg>
                                        </button>
                                    </td>
                                </tr>
                            @empty
                                <tr>
                                    <td colspan="5">No jobs found yet</td>
                                </tr>
                            @endforelse
                        </tbody>
                    </table>
                </div>
            </div>
        </div>
    </div>

    <div class="mt-4">
        {{ $jobs->links() }}
    </div>
</div>

The view is styled with tailwindcss and preline ui classes, i displayed a button at the top to add new new job, next there is an input text to filter the jobs listing using wire:model.live and bind it to $search property. Whenever you type into the search input we do a live search:

<input type="text" wire:model.live.debounce.300ms="search" />

In each row of the jobs table we displayed the job title, company, salary and the action buttons. At the bottom we display the pagination links.

 

Jobs Create / Update

The create and update jobs functionality in the JobForm component. Open JobForm.php in Livewire/Jobs/ directory.

<?php

namespace App\Livewire\Jobs;

use App\Models\CandidateJob;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;

class JobForm extends Component
{
    public $title, $company, $location, $description, $salary;

    public $job;

    public function mount($id = null)
    {
        if($id) {
            $this->job = CandidateJob::find($id);

            $this->fill($this->job->only(['title', 'company', 'salary', 'location', 'description']));
        }
    }

    public function submit()
    {
        $validated = $this->validate([
            'title' => 'required',
            'company' => 'required',
            'location' => 'required',
            'description' => 'required',
            'salary' => 'nullable'
        ]);

        //
        if(!$this->job) {
            $this->create($validated);
        } else {
            $this->update($validated);
        }
    }

    private function create($payload)
    {
        Auth::user()->candidateJobs()->create($payload);

        session()->flash('success', 'Job created successfully!');
        return redirect()->route('jobs.list');
    }

    private function update($payload)
    {
        $this->job->update($payload);

        session()->flash('success', 'Job updated successfully!');
        return redirect()->route('jobs.list');
    }

    public function render()
    {
        return view('livewire.jobs.job-form');
    }
}

Open the view in resources/views/livewire/jobs/job-form.blade.php

<div class="max-w-[85rem] px-4 py-10 sm:px-6 lg:px-8 lg:py-14 mx-auto">
    <div class="mx-auto max-w-2xl">
        <div class="text-center">
            <h2 class="text-xl text-gray-800 font-bold sm:text-3xl dark:text-white">
                {{ isset($job) ? 'Edit Job' : 'Create Job' }}
            </h2>
        </div>

        <!-- Card -->
        <div class="mt-5 p-4 relative z-10 bg-white border border-gray-200 rounded-xl sm:mt-10 md:p-10 dark:bg-neutral-900 dark:border-neutral-700">
            <form wire:submit.prevent="submit">
                <div class="mb-4 sm:mb-8">
                    <label class="block mb-2 text-sm font-medium dark:text-white">Job Title</label>
                    <input type="text" wire:model.defer="title" name="title" class="py-2.5 sm:py-3 px-4 block w-full border-gray-200 rounded-lg sm:text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600" placeholder="Job Title">
                    @error('title')
                      <div class="text-red-500 text-sm">{{ $message }}</div>
                    @enderror
                </div>

                <div class="mb-4 sm:mb-8">
                    <label class="block mb-2 text-sm font-medium dark:text-white">Company</label>
                    <input type="text" wire:model.defer="company" name="company" class="py-2.5 sm:py-3 px-4 block w-full border-gray-200 rounded-lg sm:text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600" placeholder="Company">
                    @error('company')
                        <div class="text-red-500 text-sm">{{ $message }}</div>
                    @enderror
                </div>

                <div class="mb-4 sm:mb-8">
                    <label class="block mb-2 text-sm font-medium dark:text-white">Location</label>
                    <input type="text" wire:model.defer="location" name="location" class="py-2.5 sm:py-3 px-4 block w-full border-gray-200 rounded-lg sm:text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600" placeholder="Company">
                    @error('location')
                        <div class="text-red-500 text-sm">{{ $message }}</div>
                    @enderror
                </div>

                <div class="mb-4 sm:mb-8">
                    <label class="block mb-2 text-sm font-medium dark:text-white">Salary</label>
                    <input type="text" wire:model.defer="salary" name="salary" class="py-2.5 sm:py-3 px-4 block w-full border-gray-200 rounded-lg sm:text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600" placeholder="Company">
                    @error('salary')
                        <div class="text-red-500 text-sm">{{ $message }}</div>
                    @enderror
                </div>

                <div>
                    <label class="block mb-2 text-sm font-medium dark:text-white">Description</label>
                    <div class="mt-1">
                        <textarea name="description" wire:model.defer="description" rows="3" class="py-2.5 sm:py-3 px-4 block w-full border-gray-200 rounded-lg sm:text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600"></textarea>
                    </div>
                    @error('description')
                        <div class="text-red-500 text-sm">{{ $message }}</div>
                    @enderror
                </div>

                <div class="mt-6 grid">
                    <button type="submit" class="w-full py-3 px-4 inline-flex justify-center items-center gap-x-2 text-sm font-medium rounded-lg border border-transparent bg-blue-600 text-white hover:bg-blue-700 focus:outline-hidden focus:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none">{{ isset($job)?'Update':'Submit' }}</button>
                </div>
            </form>
        </div>
        <!-- End Card -->
    </div>
</div>

In the JobForm component i declared the properties to be used in the form using wire:model directive such as title, company, location, etc.

I added the mount() lifecycle hook which accepts the $id. The $id is captured from the url in case of updating a job. Inside the mount() we retrieve the current job by id, then we fill the properties using $this->fill() method.

The submit() action is responsible for submitting the form. First i validate the form, next we check if there is an update of create operation by check for the $job property. If this is create then we invoke create() method otherwise we invoke the update() method.

The form is rendered inside the view form file job-form. Each field is bound using wire:model.defer directive. Then we displayed the error messages under each field.

 

Jobs Display In The Site

After finishing the jobs CRUD, let’s display the jobs in the homepage. We will create a livewire component that acts as the home and replace it instead of the default welcome page.

php artisan livewire:make site.job-board

php artisan livewire:make site.job-show

This command will create two livewire components inside of Livewire/Site/ directory. The job-board component is the home component, while the job-show component will display the job details page.

app/Livewire/Site/JobBoard.php

<?php

namespace App\Livewire\Site;

use App\Models\CandidateJob;
use Livewire\Component;
use Livewire\WithPagination;

class JobBoard extends Component
{
    use WithPagination;

    public $search = '';

    public function render()
    {
        $jobs = CandidateJob::query();

        if($this->search) {
            $jobs->where('title', 'like', "%{$this->search}%");
        }

        $jobs = $jobs->latest()
            ->paginate(30);

        return view('livewire.site.job-board', ['jobs' => $jobs])
            ->layout('components.layouts.site');
    }
}

In the render() method, we retrieve the latest jobs and send them to the component view. The jobs is filtered using the $search property defined at the top of the class if this property not empty. 

As shown i am using a different layout for these component, which we will create below and tells the component about the layout using view()->layout('component.layouts.site') method.

This is the code for the job-board blade view:

resources/views/livewire/site/job-board.blade.php

<div>
    <div class="bg-blue-50 py-20 text-center">
        <h1 class="text-4xl font-bold text-blue-800 mb-4">Find Your Dream Job</h1>
        <p class="text-blue-700 text-lg mb-6">Search through hundreds of job listings to find the perfect opportunity.</p>
        <div class="max-w-lg mx-auto">
            <input type="text" wire:model.live.debounce.300ms="search" class="py-2.5 sm:py-3 px-4 block w-full border-gray-200 rounded-lg sm:text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600" placeholder="Search jobs...">
        </div>
    </div>

    <div class="p-6 bg-white rounded shadow mt-8">
        <div class="flex justify-between items-center mb-6">
            <h2 class="text-2xl font-semibold">Latest Job Listings</h2>
        </div>

        @if($jobs->count())
            <div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
                @foreach($jobs as $job)
                    <div
                            class="group flex flex-col h-full border border-gray-200 rounded-xl shadow-sm transition hover:shadow-md p-5 bg-white"
                            x-data="{
            savedJobs: JSON.parse(localStorage.getItem('savedJobs') || '[]'),
            toggleSave(id) {
              if (this.savedJobs.includes(id)) {
                this.savedJobs = this.savedJobs.filter(jobId => jobId !== id);
              } else {
                this.savedJobs.push(id);
              }
              localStorage.setItem('savedJobs', JSON.stringify(this.savedJobs));
            },
            isSaved(id) {
              return this.savedJobs.includes(id);
            }
          }"
                            x-init="$watch('savedJobs', val => localStorage.setItem('savedJobs', JSON.stringify(val)))"
                    >
                        <div class="mb-3">
                            <div class="flex justify-between items-start">
                                <div>
                                    <h3 class="text-lg font-semibold text-gray-800 group-hover:text-blue-600">
                                        <a href="{{ route('jobs.show', $job) }}">
                                            {{ $job->title }}
                                        </a>
                                    </h3>
                                    <p class="text-sm text-gray-500">{{ $job->company }} — {{ $job->location }}</p>
                                </div>
                                <button
                                        @click="toggleSave({{ $job->id }})"
                                        :class="isSaved({{ $job->id }}) ? 'text-yellow-400' : 'text-gray-400'"
                                        class="ml-2 cursor-pointer hover:text-yellow-500 transition"
                                        title="Save Job"
                                >
                                    <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path d="M5.05 4.05a7 7 0 1 1 9.9 9.9l-4.95 4.95-4.95-4.95a7 7 0 0 1 0-9.9z"/></svg>
                                </button>
                            </div>
                        </div>
                        <p class="text-sm text-gray-600 mb-4 line-clamp-3">{{ $job->description }}</p>
                        <div class="mt-auto">
                           <a href="{{ route('jobs.show', $job) }}" class="inline-flex items-center gap-2 text-sm font-medium text-blue-600 hover:underline">
                            View Details
                                <svg class="w-3 h-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8h10m0 0L9 4m4 4-4 4"/></svg>
                            </a>
                        </div>
                    </div>
                @endforeach
            </div>
            <div class="mt-6">
                {{ $jobs->links() }}
            </div>
        @else
            <p class="text-center text-gray-500">No job listings found.</p>
        @endif
    </div>
</div>

At the top of the page, i am displaying a hero section with a search box to filter the jobs:

<input type="text" wire:model.live.debounce.300ms="search" placeholder="Search jobs...">

Once you start typing, the $search property will intercept this change, and reload the filtered jobs.

The second section of the page displays the latest jobs using foreach. Each job displayed in a card layout along with the job title, location, company, salary. There is a nice icon to add this job as a favorite job by storing it in local storage.

To store it in local storage, we utilize Alpine JS by defining an object with methods and properties in the x-data directive in the div surrounding each job like so:

 <div x-data="{
            savedJobs: JSON.parse(localStorage.getItem('savedJobs') || '[]'),
            toggleSave(id) {
              if (this.savedJobs.includes(id)) {
                this.savedJobs = this.savedJobs.filter(jobId => jobId !== id);
              } else {
                this.savedJobs.push(id);
              }
              localStorage.setItem('savedJobs', JSON.stringify(this.savedJobs));
            },
            isSaved(id) {
              return this.savedJobs.includes(id);
            }
          }"

></div>

Any property or method defined in the x-data directive can be referenced in the nested template, as shown we listen for @click event and assign the toggleSave() like:

<button @click="toggleSave({{ $job->id }})"
                                       
                                        title="Save Job"
                                >
                                   
                                </button>

Now create the layout file resources/views/components/layouts/site.blade.php

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>{{ $title ?? config('app.name') }}</title>

    <link rel="icon" href="/favicon.ico" sizes="any">
    <link rel="icon" href="/favicon.svg" type="image/svg+xml">
    <link rel="apple-touch-icon" href="/apple-touch-icon.png">

    <link rel="preconnect" href="https://fonts.bunny.net">
    <link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />

    @vite(['resources/css/app.css', 'resources/js/app.js'])
    @fluxAppearance

</head>
<body class="bg-[#FDFDFC] dark:bg-[#0a0a0a] text-[#1b1b18] flex p-6 lg:p-8 items-center lg:justify-center min-h-screen flex-col">
<header class="w-full lg:max-w-4xl max-w-[335px] text-sm mb-6 not-has-[nav]:hidden">
    @if (Route::has('login'))
        <nav class="flex items-center justify-end gap-4">
            @auth
                <a
                        href="{{ url('/dashboard') }}"
                        class="inline-block px-5 py-1.5 dark:text-[#EDEDEC] border-[#19140035] hover:border-[#1915014a] border text-[#1b1b18] dark:border-[#3E3E3A] dark:hover:border-[#62605b] rounded-sm text-sm leading-normal"
                >
                    Dashboard
                </a>
            @else
                <a
                        href="{{ route('login') }}"
                        class="inline-block px-5 py-1.5 dark:text-[#EDEDEC] text-[#1b1b18] border border-transparent hover:border-[#19140035] dark:hover:border-[#3E3E3A] rounded-sm text-sm leading-normal"
                >
                    Log in
                </a>

                @if (Route::has('register'))
                    <a
                            href="{{ route('register') }}"
                            class="inline-block px-5 py-1.5 dark:text-[#EDEDEC] border-[#19140035] hover:border-[#1915014a] border text-[#1b1b18] dark:border-[#3E3E3A] dark:hover:border-[#62605b] rounded-sm text-sm leading-normal">
                        Register
                    </a>
                @endif
            @endauth
        </nav>
    @endif
</header>
<div class="flex items-center justify-center w-full transition-opacity opacity-100 duration-750 lg:grow starting:opacity-0">
    <main class="flex max-w-[335px] w-full flex-col-reverse lg:max-w-4xl lg:flex-row">
        {{ $slot }}

    </main>
</div>

@if (Route::has('login'))
    <div class="h-14.5 hidden lg:block"></div>
@endif

@fluxScripts
</body>
</html>

Update routes/web.php, remove the route of the welcome page and replace it:

Route::get('/', \App\Livewire\Site\JobBoard::class)->name('home');
Route::get('/job/{id}', \App\Livewire\Site\JobShow::class)->name('jobs.show');

 

Display job details

Update the other component, the job-show component which display the details of the job. 

app/Livewire/Site/JobShow.php

<?php

namespace App\Livewire\Site;

use App\Models\CandidateJob;
use Livewire\Component;

class JobShow extends Component
{
    public $job;

    public function mount($id)
    {
        $this->job = CandidateJob::find($id);
    }

    public function render()
    {
        return view('livewire.site.job-show')
            ->layout('components.layouts.site');
    }
}

The component view code:

resources/views/livewire/site/job-show.blade.php

<div class="max-w-4xl mx-auto py-16 px-6">
    <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
        <h1 class="text-3xl font-bold text-gray-900 mb-4">{{ $job->title }}</h1>
        <p class="text-gray-600 mb-1 text-sm">Company: <span class="font-medium text-gray-800">{{ $job->company }}</span></p>
        <p class="text-gray-600 mb-4 text-sm">Location: <span class="text-gray-800">{{ $job->location }}</span></p>

        <div class="mb-6">
            <h2 class="text-lg font-semibold text-gray-800 mb-2">Job Description</h2>
            <p class="text-gray-700 leading-relaxed">{!! nl2br($job->description) !!}</p>
        </div>

        <a href="{{ url('/') }}" wire:navigate class="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-600 hover:text-blue-800">
            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
                <path stroke-linecap="round" stroke-linejoin="round" d="M6.75 15.75 3 12m0 0 3.75-3.75M3 12h18" />
            </svg>
            Back to listings
        </a>
    </div>
</div>

 

Source code can be found on this repository.

0 0 votes
Article Rating

What's your reaction?

Excited
0
Happy
0
Not Sure
0
Confused
0

You may also like

Subscribe
Notify of
guest

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments