It really isn't practical in a single answer to give a full list of security best practices for something as major as webapp password authentication. Even narrowing it down to PHP doesn't help that much.
A few things that come to mind specifically from what you've said, though:
- PHP is a fully automatic foot-gun when it comes to security; it's possible to write secure code in it but it's much, much easier to make security mistakes than in most other languages due to things like loose variable declaration, loose typing with automatic type coercion, and major APIs returning a result even when they fail instead of throwing an exception or otherwise clearly indicating an error. You can mitigate at least some of these (and a good linter can get the rest) but you have to do so / use it consistently. That's not even counting old-school PHP's security disasters like automatic parameter binding to variables.
- Unless you're doing something very uncommon, using both PBKDF2 and bcrypt for password hashing is unlikely. Also, those are both very old functions with notable weaknesses (PBKDF2 isn't memory-hard at all and bcrypt has low memory demand by modern standards, so both can be parallelized more efficiently than is ideal); there's no good reason not to use newer functions instead. Argon2id is very solid.
Some other pieces of advice based on my pentesting experience (note that these aren't PHP- or MVC-specific, and may be overkill depending on the site in question):
- Even when people get all the security controls right on the login page, they often forget something on the pages for registration, requesting a password reset, or performing the password reset.
- Password reset tokens need to be cryptographically securely random, long enough to prevent brute force, short-lived, and single-use.
- You almost certainly want to use either cryptographically secure random session tokens, or JWTs with refresh tokens. While other kinds of session token exist, they are usually either poorly designed or their libraries are badly implemented (or both). Random tokens are simple and JWTs have good libraries.
- I really can't stress enough the need for cryptographically secure random numbers. Any time you need anything random, use one. MT_rand and similar PRNGs designed primarily for speed, or anything where you are expected to seed it / where it automatically seeds itself from the current time or a constant value, are NOT secure.
- If you use JWTs, they need to have a super short lifetime (a few minutes is a good max). You can't revoke a JWT (when a user logs out or tries to end other sessions, assuming that's a feature you support), only wait for it to expire (and hope an attacker doesn't use it in the meantime), so keep that time-to-expiry short!
- You can put session tokens in either cookies or custom headers. Cookies seem easy but you need to use the secure flag, and should also use httponly and samesite=lax. Note that unless you are VERY sure that all your users are on modern browsers and that you fully control all content on the domain including all subdomains, you need additional CSRF protection when using cookie-based sessions; samesite isn't enough. If using custom headers you don't need to worry about CSRF (unless you've made very bad CORS decisions)
- The simplest correct CORS decision is to not support it at all and never return any Access-Control-Allow-Origin header. If you must support CORS, allow only the origins you control or at least fully trust.
- Do log every authentication attempt (succeed or fail), including the result, timestamp, for whom, and where (IP) it comes from. Do NOT log credentials (including passwords, password reset tokens, session tokens, etc.), ever.
- Support multi-factor authentication. Password authentication alone just isn't very secure online. Ideally use something stronger than SMS OTP codes. WebAuthn is extremely strong (among other things, it's basically the only form that prevents phishing attacks from succeeding) and supported on almost all devices and browsers these days.
- Prevent brute-force attacks on passwords. Ideally in a way that doesn't let malicious individuals prevent individual users, or everybody else behind the same public IP, from logging into your site. CAPTCHAs, or emailing the user a link to use when their account is under attack and somebody enters the right password, are both options.
- Prevent guessable or compromised passwords. Password complexity rules are nigh-useless, don't use them. Instead, require some decent minimum password length - eight characters is the bare minimum, 10 or 12 is better - and disallow strings such as the site name or user's name.
- Make sure that the password isn't one that has ever been breached on any site. Use a list / service such as from https://haveibeenpwned.com/Passwords to verify this. Don't forget to require it when changing/resetting passwords too! Consider checking on every login, if you want to catch use of newly-breached passwords. If you use a local copy of the password list, keep it up to date. This approach not only stops "credential stuffing" attacks, it's also way more effective at catching every stupid variation of "P@ssw0rd" that people might try than any handmade list will be.
- Use HTTP Strict Transport Security, with a long lifetime (ideally at least a year), include subdomains, and set up HSTS preloading. Don't ever do anything with an insecure HTTP request except to redirect (immediately and with no other processing) to HTTPS.
- If you support controllable redirection after logging in, don't allow arbitrary redirection destinations (open redirects) and make sure there's no XSS vectors (e.g. that the redirect isn't placed into the DOM without context-sensitive escaping).
- The best way to prevent SQL injection is pre-write the queries on the database server and just use parameters, and disallow ad-hoc queries for your DB user. MySQL supports stored procedures (sprocs), with strongly-typed parameters, and you can (and should) remove all permissions for a user other than executing your defined procedures (see https://stackoverflow.com/questions/41665626). You still need to not use string concatenation within the sprocs, though! If you don't want to use sprocs, use parameterized queries or prepared statements (without string concatenation!) anyhow, though this requires the DB user be able to execute queries that could be abused if they get SQL injection. If you want to be super secure, create a bunch of DB users, each with minimal permissions to perform only a single task, as that way a SQLi in e.g. the code for looking up a user can't be used to alter a user's password.
- Don't make your DB (or any other back-end services) publicly reachable! Only expose the minimum amount of attack surface, which is generally the web app and nothing else.
- Don't ever authorize access to anything - either a function or an object - simply by guessing a URL. (This is known as IDOR, Insecure Direct Object Reference.) If you absolutely MUST allow access to a secure resource purely by URL (e.g. Google Docs' "anybody with this link" sharing mode), that URL should be very high entropy (contain a long, cryptographically secure random token) so that attackers can't guess or predict it.
This is not an exhaustive answer, nor is it ideally organized. There are tons of resources (admittedly, not always good) out there for building auth and session management systems. OWASP has a bunch. StackOverflow has a bunch (some are old and there are better approaches now, though).
Also, remember that login pages and so on are part of a web app, and need all the usual web app security. I mentioned some stuff (like HSTS and the most common place I found XSS) in my answer, but there's a lot more than that!