Go & Vue PHP & Vue Node & Vue

Full-Stack software engineer

Píšu čistý a bezpečný kód, který udrží zdraví Vašeho projektu.

Jsem on-line:
09:00 - 16:00 01:00 - 08:00
Jiří Zapletal - PHP & Vue developer

Hledáte vývojáře webových aplikací? Neváhejte se na mě obrátit.

O mně

Jsem programátor a vývojář webových aplikací všech druhů. Mám přes 17 let zkušeností s vývojem softwaru. Posílím Váš vývojářský tým, nebo mi klidně svěřte celý projekt od návrhu po realizaci.

Vyvíjím aplikace pro firmy a instituce jako GoPay, Jihočeská univerzita (Bobřík Informatiky), Websta Solutions, Střechy Bohemia, Osam Trade a další. Pracuji i pro jednotlivce a drobné podnikatele.

Na backendu nejraději používám PHP 8.5, PHPStan level 10 s vlastními pravidly a standard PER-3. Stavím na Doctrine ORM a jednotlivých komponentách Symfony, Nette i Laravelu, a když je potřeba, sáhnu po kompletním frameworku nebo vlastním nástroji. Produkční kód píšu vedle PHP i v Go. Na frontendu preferuji TypeScript, Vite, Vue 3 s composition API a Tailwind CSS.

Zakládám si na architektuře a návrhových vzorech. Velkou část kódu dnes generuje AI, a tak z revizí píšu deterministická pravidla, která pak hlídá CI. Čím je kód striktnější, tím lépe. Vše pokrývám testy: PHPUnit v PHP, Vitest na backendu i frontendu a Playwright na end-to-end scénáře.

Na GitHubu mám přes 150 repozitářů, veřejných i privátních, a přispívám do několika open-source projektů. Vyvinul jsem si i vlastní nástroje a frameworky pro rychlejší prototypování, vývoj a dlouhodobou udržitelnost webových aplikací, například GoKick. Postavil jsem i 3D hru pro více hráčů. Quackpocalypse běží v prohlížeči bez jakéhokoli herního enginu.

Jednám na rovinu, držím slovo a vycházím vstříc. Stojím pouze o férovou spolupráci a vyžaduji ji i z druhé strany.

Můj příběh
Jiří Zapletal - Web developer

Technologie a nástroje, které ovládám

PHP
PHP Symfony Laravel Nette Doctrine ORM Guzzle
Go
Go net/http Goroutines Wire (DI) sqlx Goose Cobra slog
Node.js
Node Nuxt Next SvelteKit Strapi Crawlee Apify SDK Puppeteer Cheerio
Frontend
Vite TypeScript Vanilla JS Vue Svelte React Tailwind Bootstrap SCSS
Testing
PHPUnit Codeception Nette Tester Vitest Playwright
Code quality
PHPStan PER-3 Eslint GoArchLint GoLines knip commitlint
DB & Storage
Postgres MySQL SQLite MariaDB AWS Aurora RabbitMQ Redis
API
JWT OAuth REST GraphQL CURL Postman
Dev-Ops
Docker Kubernetes Shell Nginx Apache PHP-FPM Dokploy Easypanel Grafana Prometheus OTel Sentry
Cloud
Google Cloud AWS Digital Ocean AppRunner S3 ECR RDS SES Apify platform
GIT
GitHub GitLab BitBucket
UX / UI
Figma Affinity Balsamiq Mockups
AI Tools
Claude CLI Claude Cursor Github Copilot MCPs Skills Anthropic API OpenAI API Gemini API
Nástroje
GoLand PHP Storm JIRA Scrum Slack TogglTrack Google Meet Trello
Experimentálně
Elastic MongoDB Pocketbase.io Google Firebase
Hobby
C# C++ Unity Unreal Engine Three.js Game dev VR dev

Jak vypadá můj zdrojový kód

Gameplay\RateAnswerFacade.php
<?php
declare(strict_types=1);

namespace App\Gameplay\Facade;

use App\EntityManager;
use App\Gameplay\Facade\Exception\GameplayFacadeException;
use App\Gameplay\Http\Request\Dto\RateAnswerDto;
use App\Student\Database\Entity\Student;
use App\Student\Database\Enum\Gender;
use App\StudentGroup\Database\Entity\StudentGroup;
use App\Task\Database\Entity\Task;
use App\Task\Database\Enum\Liked;
use App\Task\Database\Repository\AnswerRepository;

/**
 * Records the group's feedback on a task (Bobřík enum: 1=Yes, -1=No, 0=Neutral).
 * Re-rating overwrites the previous value.
 */
final readonly class RateAnswerFacade
{
    public function __construct(
        private EntityManager $em,
        private AnswerRepository $answerRepository,
    ) {}

    /**
     * @throws GameplayFacadeException when the answer doesn't belong to the
     *                                 caller's group (prevents drive-by
     *                                 rating of other groups' rows).
     */
    public function execute(RateAnswerDto $dto, StudentGroup $group, Student $caller): void
    {
        $answer = $this->answerRepository->findById($dto->answerId);
        if ($answer === null) {
            throw new GameplayFacadeException(translationKey: 'gameplay.error.answer_not_found');
        }

        if ($answer->getStudentGroup()->getId() !== $group->getId()) {
            throw new GameplayFacadeException(translationKey: 'gameplay.error.answer_not_owned');
        }

        // Author was the only one who could have clicked submit, so they are
        // also the only one who can rate — keeps the audit trail clean.
        if ($answer->getAuthorStudent()->getId() !== $caller->getId()) {
            throw new GameplayFacadeException(translationKey: 'gameplay.error.answer_not_owned');
        }

        $answer->setLiked(Liked::from($dto->liked));
        $this->em->flush();

        $this->recalculatePopularity($answer->getTask());
    }

    /**
     * A rating changes the task's popularity, so recompute the two stored
     * buckets from scratch. Must run *after* the rating flush above — the count
     * queries read the DB, so the new `liked` value has to be persisted first;
     * a second flush then writes the refreshed totals.
     */
    private function recalculatePopularity(Task $task): void
    {
        $taskId = $task->getId();
        $maleLikes = $this->answerRepository->countLikesByTaskAndGender($taskId, Gender::Male);
        $femaleLikes = $this->answerRepository->countLikesByTaskAndGender($taskId, Gender::Female);
        $otherLikes = $this->answerRepository->countLikesByTaskAndGender($taskId, Gender::Other);

        $task->setPopularityMale($maleLikes);
        $task->setPopularityFemale($femaleLikes);
        $task->setPopularityOther($otherLikes);
        $this->em->flush();
    }
}
profile/change_password.go
package command

import (
	"context"
	"errors"
	"time"

	"gokick/app/domain/shared"
	"gokick/app/domain/shared/msgkey"
	"gokick/app/domain/user"
)

type ChangePasswordCommand struct {
	OldPassword string
	NewPassword string
}

func (ChangePasswordCommand) RequiredPermission() string { return "profile:update" }

type ChangePasswordHandler struct {
	users    user.Repository
	password shared.PasswordHasher
}

func NewChangePasswordHandler(
	users user.Repository,
	password shared.PasswordHasher,
) *ChangePasswordHandler {
	return &ChangePasswordHandler{
		users:    users,
		password: password,
	}
}

func (h *ChangePasswordHandler) Handle(ctx context.Context, cmd ChangePasswordCommand) error {
	claims, err := shared.RequireClaims(ctx)
	if err != nil {
		return err
	}

	u, err := h.users.FindByID(ctx, claims.UserID)
	if err != nil {
		return err
	}
	if u == nil {
		return &shared.ValidationError{Field: "general", Key: msgkey.UserNotFound}
	}

	if err := h.password.Verify(cmd.OldPassword, u.PasswordHash); err != nil {
		return &shared.AuthError{Key: msgkey.AuthCurrentPasswordIncorrect}
	}

	newHash, err := user.HashNewPassword(cmd.NewPassword, h.password)
	if err != nil {
		var ve *shared.ValidationError
		if errors.As(err, &ve) {
			return &shared.ValidationError{Field: "new_password", Key: ve.Key, Params: ve.Params}
		}
		return err
	}

	if err := h.users.UpdatePassword(ctx, u.ID, newHash, time.Now()); err != nil {
		return err
	}

	shared.AuditCollectorFromContext(ctx).Record(shared.AuditEvent{
		Action:     "user.password_changed",
		TargetType: "user",
		TargetID:   u.ID,
	})

	return nil
}
UserEditModal.vue
<script setup lang="ts">
import type { AdminUser } from '@/app/Admin/types/AdminUser';
import { isAdminUser } from '@/app/Admin/types/AdminUser';
import type { UserFormData } from '@/app/Admin/types/UserFormData';
import type { UserFormErrors } from '@/app/Admin/types/UserFormErrors';
import { ref, watch } from 'vue';
import { authFetch, useAuth } from '@/app-ui/Auth';
import { getLocale, localizePath, useI18n } from '@/app-ui/I18n';
import { useToast } from '@/app-ui/Toast/useToast';
import Modal from '@/app-ui/Modals/Modal.vue';
import Spinner from '@/app-ui/Loading/Spinner.vue';
import UserForm from '@/app/Admin/Components/UserForm.vue';

const { userId } = defineProps<{
    userId: string | null;
}>();

const emit = defineEmits<{
    saved: [nickname: string];
    close: [];
}>();

const { t } = useI18n();
const { error } = useToast();
const { user: currentUser } = useAuth();

const initial = ref<UserFormData | null>(null);
const errors = ref<UserFormErrors>({});
const isLoading = ref<boolean>(false);
const isFetching = ref<boolean>(false);

const clearFieldError = (field: keyof UserFormErrors): void => {
    delete errors.value[field];
};

const close = (): void => {
    errors.value = {};
    emit('close');
};

const loadUser = async (id: string): Promise<void> => {
    // Blank first — the previous subject must not stay on screen while the new one loads.
    initial.value = null;
    errors.value = {};
    isFetching.value = true;

    const result = await authFetch<AdminUser>('GET', `/api/v1/admin/users/${id}`, {
        validate: isAdminUser,
    });

    isFetching.value = false;

    if (result.success === false) {
        error(t('users.load_one_failed'));
        close();

        return;
    }

    initial.value = {
        nickname: result.data.nickname,
        password: '',
        email: result.data.email,
        role: result.data.role,
    };
};

watch(() => userId, (id: string | null): void => {
    if (id === null) {
        return;
    }

    void loadUser(id);
});

const handleSubmit = async (data: UserFormData): Promise<void> => {
    const id = userId;

    if (id === null) {
        return;
    }

    isLoading.value = true;
    errors.value = {};

    const result = await authFetch<null, UserFormErrors, UserFormData>(
        'PUT',
        `/api/v1/admin/users/${id}`,
        { body: data },
    );

    isLoading.value = false;

    if (result.success === false) {
        errors.value = result.data;

        return;
    }

    const isSelf = currentUser.value !== null && currentUser.value.id === id;
    const roleChanged = initial.value !== null && initial.value.role !== data.role;

    if (isSelf === true && roleChanged === true) {
        // Admin demoted themselves: full reload so bootstrap mints a token with
        // the new permissions. A router navigation would keep the stale JWT.
        window.location.assign(localizePath('/dashboard', getLocale()));

        return;
    }

    emit('saved', data.nickname);
    close();
};
</script>

<template>
    <Modal
        :show="userId !== null"
        :title="t('users.edit_title')"
        @close="close"
    >
        <div
            v-if="isFetching === true"
            class="flex items-center justify-center py-12"
        >
            <Spinner />
        </div>

        <UserForm
            v-else-if="initial !== null"
            mode="edit"
            :submit-label="t('common.save')"
            :initial="initial"
            :is-loading="isLoading"
            :errors="errors"
            @submit="handleSubmit"
            @cancel="close"
            @clear-error="clearFieldError"
        />
    </Modal>
</template>
PersonalInfo.tsx
import * as yup from 'yup';
import { useAtom } from 'jotai';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { personalDataStore, stepStore } from '../store/store';
import Header from '../components/section/Header';
import NavButtons from '../components/section/NavButtons';
import TextInput from '../components/forms/inputs/TextInput';

type FormValues = {
  name: string;
  email: string;
  phone: string;
};

const schema = yup.object({
  name: yup.string().required('Name is required'),
  email: yup
    .string()
    .required('Email is required')
    .test((value, context) => {
      return /^\S+@\S+\.\S+$/.test(value) || context.createError({ message: 'E-mail is not valid' });
    }),
  phone: yup
    .string()
    .required('Phone is required')
    .test((value, context) => {
      return /^(\+?420)?(\d?){9}$/.test(value) || context.createError({ message: 'Phone is not valid' });
    })
    .transform((value) => (value ? value.replace(/\s/g, '') : value)),
});

function PersonalInfo(): JSX.Element {
  const [, setStep] = useAtom(stepStore);
  const [pData, setPersonalData] = useAtom(personalDataStore);

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormValues>({ resolver: yupResolver(schema), values: pData });

  const onSubmit = handleSubmit((data) => {
    setPersonalData(data);
    setStep(2);
  });

  return (
    <form onSubmit={onSubmit} className="grid grid-cols-1 place-content-between h-full">
      <Header
        title="Personal info"
        description="Please provide your name, email address, and phone number."
      />
      <div className="grid gap-5 mt-5 xl:mt-0">
        <TextInput label="Name" fieldName="name" error={errors.name} register={register} />
        <TextInput label="E-mail Address" fieldName="email" error={errors.email} register={register} />
        <TextInput
          label="Phone Number"
          fieldName="phone"
          placeholder="e.g. +420 123 456 789"
          error={errors.phone}
          register={register}
        />
      </div>
      <NavButtons btnText="Next step" />
    </form>
  );
}

export default PersonalInfo;
firebase/scraper.ts
import * as admin from 'firebase-admin'
import * as functions from 'firebase-functions'
import { CallableContext } from 'firebase-functions/lib/providers/https'
import { ComparatorResult } from './types/ComparatorResult'
import { AllowedTypes } from './types/ScraperType'
import Puppeteer from './scraper/puppeteer'

admin.initializeApp()

type ScraperRequest = {
    links: string[]
    scraperType: string
}

export const runPuppeteer = functions
.region('europe-west3')
.runWith({
    timeoutSeconds: 120,
    memory: '4GB',
    failurePolicy: false
})
.https.onCall(async (data: ScraperRequest, context: CallableContext): Promise<Array<ComparatorResult>> => {
    const comparatorResults: Array<ComparatorResult> = []
    const links = data.links
        .filter((item, index, self) => self.indexOf(item) === index)
        .splice(0, 10)

    if (! AllowedTypes.includes(data.scraperType)) {
        comparatorResults.push({
            error: `Scraper typu '${data.scraperType} neexistuje.'`,
            url: null,
            duration: null,
            listingResult: null,
            debugLink: null
        })
        return comparatorResults
    }

    const scraper = new Puppeteer(data.scraperType, true, null, 1500)
    await scraper.createBrowser(1920, 1080)

    for (const targetUrl of links) {
        try {
            const comparatorResult = await scraper.getPageResult(targetUrl)
            comparatorResults.push(comparatorResult)
        } catch (exception) {
            comparatorResults.push({
                error: exception instanceof Error ? exception.message : String(exception),
                url: targetUrl,
                duration: null,
                listingResult: null,
                debugLink: null
            })
        }
    }

    await scraper.closeBrowser()

    return comparatorResults
})
Dockerfile
FROM node:24-alpine AS build-stage-node
WORKDIR /build

COPY . ./

RUN yarn cache clean --mirror
RUN yarn && yarn build

FROM php:8.4-fpm-alpine
WORKDIR /var/www/html

# Set timezone
ENV TZ="Europe/Prague"

# Nginx & PHP configs
COPY ./docker/nginx/nginx.conf /etc/nginx/nginx.conf
COPY ./docker/nginx/http.d/default.conf /etc/nginx/http.d/default.conf
COPY ./docker/php/php.ini /usr/local/etc/php/conf.d/php.ini
# Install core linux dependencies
RUN apk add su-exec
RUN apk add openssl curl ca-certificates
RUN apk add bash nano
RUN apk add nginx

# Install opcache
RUN docker-php-ext-install opcache

# Install intl
RUN apk add --no-cache icu-dev
RUN docker-php-ext-configure intl
RUN docker-php-ext-install intl

# Install postgres
RUN apk add --no-cache libpq-dev
RUN docker-php-ext-configure pgsql -with-pgsql=/usr/local/pgsql
RUN docker-php-ext-install pdo pdo_pgsql

# Install excimer (Sentry)
RUN apk add autoconf g++ make pcre-dev
RUN pecl install excimer
RUN docker-php-ext-enable excimer

# Copy source code
COPY . ./
COPY --from=build-stage-node /build/www/temp ./www/temp
COPY --from=build-stage-node /build/temp/latte-mail ./temp/latte-mail

# Install composer & dependencies
ENV COMPOSER_ALLOW_SUPERUSER=1
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
RUN composer install --no-cache --prefer-dist --no-scripts

# Resolve permissions
RUN chmod -R ugo+w ./temp
RUN chmod -R ugo+w ./log
RUN chmod -R ugo+r ./www/temp
RUN chown -R www-data:www-data /var/www/html

# Add entrypoint
ADD ./docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh

ENTRYPOINT ["/docker-entrypoint.sh"]
side-modal.scss
.side-modal {
    top: 0;
    left: 0;
    right: 0;
    z-index: 1005;
    background: rgba(var(--v-theme-on-surface), .32);
    transition: 300ms ease opacity;
    opacity: 0;
    pointer-events: none;

    &.opened {
        opacity: 1;
        pointer-events: auto;

        .side-modal-container {
            transform: translateX(0);
        }
    }

    &-container {
        max-width: 700px;
        width: 100%;
        z-index: 2;
        right: 0;
        transition: 300ms ease transform;
        transform: translateX(100%);
    }

    &-close {
        left: -55px;
        top: 20px
    }
}

Recenze

Slova přímo od zákazníků (můžete jim zavolat a cokoli si ověřit).

Zkušenosti prověřené desítkami klientů a stovkami jejich projektů

Jak probíhá spolupráce

  1. 1 První kontakt a konzultace Z nezávazného hovoru odejdete s rámcovým odhadem rozsahu i ceny.
  2. 2 Analýza projektu a nacenění Dostanete PDF s kompletním plánem: funkce, technologie, termíny a cena.
  3. 3 Vývoj projektu po fázích Po každé fázi máte funkční verzi, kterou si vyzkoušíte.
  4. 4 Spuštění ostrého provozu Aplikace běží v ostrém provozu, otestovaná a předaná.
  5. 5 Měření, údržba, rozvoj Sleduji provoz, opravuji chyby a přidávám nové funkce.
PDF ke stažení

Klíčové otázky před startem

Než padne první řádek kódu, projdeme spolu otázky, které rozhodují o tom, jestli má projekt smysl a co má umět nejdřív.

  1. Jaký problém má projekt vyřešit a komu?
  2. Podle čeho poznáme, že se projekt povedl?
  3. Co vás stojí dnešní stav, když se nic neudělá?
  4. Co musí umět první verze a co počká?
  5. S jakými systémy a daty se řešení propojí?
  6. Kdo bude systém provozovat a rozvíjet?
  7. Jaký máte rozpočet a termín?
7 klíčových otázek před zahájením projektu Podklad pro první schůzku (PDF) Jak se naceňuje vývoj software? Článek na blogu

Kontakt

Zdarma se pobavíme o projektu, představách a vzájemných možnostech, které povedou ke kvalitní spolupráci.

Telefon
+420 606 091 125
E-mail
[email protected]
Kde mě naleznete

Pracuji na Full-Remote z Českých Budějovic.

Sejít se můžeme v kavárně, on-line, ve VR nebo u Vás na firmě.

Fakturační údaje
Jiří Zapletal
Luční 243
Nová Bystřice 378 33
IČO: 01367137
DIČ: neplátce DPH
2301859766/2010
Strategio Digital s.r.o.
Radniční 133/1
Č. Budějovice 370 01
IČO: 09568042
DIČ: neplátce DPH
2801878605/2010

Dostávejte to nejnovější z oblasti vývoje aplikací rovnou na Váš e-mail.

Menu
Máte dotazy?

ZDARMA Vám poskytnu konzultaci, poradím a navrhnu řešení.

Zanechte mi vzkaz nebo se mi rovnou ozvěte na některý z uvedených kontaktů.

Jiří Zapletal

Full-Stack software engineer na volné noze. 17 let zkušeností v oblasti vývoje. Stovky realizovaných projektů.

Telefon: +420 606 091 125
© 2026 | copy design dev & mkt by Jiří Zapletal